first
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""Google Analytics client.
|
||||
|
||||
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
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from .. import config
|
||||
from ..credentials import GaCredentials
|
||||
from ..errors import MissingCredentialsError, UpstreamError
|
||||
from ..logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# In-memory access-token cache: { cache_key: (token, expiry_epoch_seconds) }.
|
||||
# Memory only - mirrors the stateless design (no secret ever persisted).
|
||||
_token_cache: dict[str, tuple[str, float]] = {}
|
||||
_token_lock = threading.Lock()
|
||||
|
||||
# Refresh a minted token this many seconds before its real expiry.
|
||||
_EXPIRY_SKEW = 60.0
|
||||
|
||||
|
||||
def _mint_token_sync(info: dict, scope: str) -> tuple[str, float]:
|
||||
"""Mint an OAuth2 access token from a service-account key (blocking)."""
|
||||
# Imported lazily so the module imports even if google-auth is missing,
|
||||
# and so the dependency is only needed when service-account auth is used.
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2 import service_account
|
||||
|
||||
try:
|
||||
creds = service_account.Credentials.from_service_account_info(
|
||||
info, scopes=[scope]
|
||||
)
|
||||
except (ValueError, KeyError) as exc:
|
||||
raise MissingCredentialsError(
|
||||
f"X-GA-Credentials is not a usable service-account key: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
except Exception as exc: # google.auth.exceptions.RefreshError and friends
|
||||
# Surface as upstream auth failure - do NOT log the key material.
|
||||
raise UpstreamError(
|
||||
f"Failed to obtain Google access token from service account: {exc}",
|
||||
status=401,
|
||||
) from exc
|
||||
|
||||
expiry = creds.expiry.timestamp() if creds.expiry else (time.time() + 3600)
|
||||
return creds.token, expiry
|
||||
|
||||
|
||||
def _cache_key(info: dict, scope: str) -> str:
|
||||
# Identify a key by its private_key_id + client_email + scope. Hashed so the
|
||||
# raw identifiers never sit in a dict key we might later log.
|
||||
raw = f"{info.get('private_key_id', '')}|{info.get('client_email', '')}|{scope}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _bearer_token(creds: GaCredentials) -> 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.
|
||||
raise MissingCredentialsError(
|
||||
"No GA access token and no service-account credentials available."
|
||||
)
|
||||
|
||||
scope = config.GA_SCOPE
|
||||
key = _cache_key(creds.service_account_info, scope)
|
||||
now = time.time()
|
||||
|
||||
with _token_lock:
|
||||
cached = _token_cache.get(key)
|
||||
if cached and cached[1] - _EXPIRY_SKEW > now:
|
||||
return cached[0]
|
||||
|
||||
# Mint outside the lock (network call); google-auth is blocking, so offload
|
||||
# it to a thread to avoid stalling the event loop.
|
||||
token, expiry = await run_in_threadpool(
|
||||
_mint_token_sync, creds.service_account_info, scope
|
||||
)
|
||||
with _token_lock:
|
||||
_token_cache[key] = (token, expiry)
|
||||
return token
|
||||
|
||||
|
||||
class GoogleAnalyticsClient:
|
||||
"""Authenticated HTTP client for the GA4 Data and Admin APIs."""
|
||||
|
||||
def __init__(self, creds: GaCredentials) -> None:
|
||||
self._creds = creds
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
base_url: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict | None = None,
|
||||
json_body: Any | None = None,
|
||||
) -> Any:
|
||||
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
|
||||
|
||||
url = f"{base_url}{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=config.HTTP_TIMEOUT_SECONDS
|
||||
) as client:
|
||||
resp = await client.request(
|
||||
method, url, params=params, json=json_body, headers=headers
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise UpstreamError(
|
||||
"Google Analytics request timed out.", status=504
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise UpstreamError(
|
||||
f"Google Analytics is unreachable: {exc}", status=502
|
||||
) from exc
|
||||
|
||||
return _parse_google_response(resp)
|
||||
|
||||
# --- 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 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
|
||||
)
|
||||
|
||||
|
||||
def _parse_google_response(resp: httpx.Response) -> Any:
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
payload = {"raw": resp.text}
|
||||
|
||||
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)
|
||||
raise UpstreamError(
|
||||
message,
|
||||
status=502 if resp.status_code >= 500 else resp.status_code,
|
||||
upstream_status=resp.status_code,
|
||||
body=payload,
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Sklik (Seznam) "Drak" JSON API client.
|
||||
|
||||
Protocol (verified against the official seznam/api-examples JSON example):
|
||||
* Endpoint: ``{SKLIK_BASE_URL}/{method}`` e.g. .../drak/json/v5/campaigns.list
|
||||
* HTTP POST, body = a JSON ARRAY of positional arguments.
|
||||
* ``client.loginByToken`` takes the API token as its single argument and
|
||||
returns ``{"status":200,"session":"...",...}``.
|
||||
* Every authenticated method takes the user struct ``{"session": ...}``
|
||||
(optionally ``"userId"``) as its FIRST argument, followed by the method's
|
||||
own arguments.
|
||||
* Every response is an object containing ``status`` (HTTP-style int),
|
||||
``statusMessage``, a refreshed ``session``, plus method-specific data.
|
||||
|
||||
The proxy is stateless: it logs in with ``X-Sklik-Token`` per request to obtain
|
||||
a session, then performs the requested call. The token and session are never
|
||||
logged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .. import config
|
||||
from ..errors import UpstreamError
|
||||
from ..logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Sklik report data is paginated; readReport is called with an offset/limit
|
||||
# window until all rows are fetched. Keep the page size conservative.
|
||||
_REPORT_PAGE_LIMIT = 100
|
||||
# Hard stop so a misbehaving upstream can't loop forever.
|
||||
_REPORT_MAX_PAGES = 1000
|
||||
|
||||
|
||||
class SklikClient:
|
||||
"""Performs JSON-RPC calls against the Sklik Drak API."""
|
||||
|
||||
def __init__(self, token: str, user_id: int | None = None) -> None:
|
||||
self._token = token
|
||||
self._user_id = user_id
|
||||
self._session: str | None = None
|
||||
|
||||
async def __aenter__(self) -> "SklikClient":
|
||||
self._http = httpx.AsyncClient(timeout=config.HTTP_TIMEOUT_SECONDS)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: Any) -> None:
|
||||
await self._http.aclose()
|
||||
|
||||
async def _call(self, method: str, args: list[Any]) -> dict:
|
||||
"""Low-level: POST a JSON array of args to ``/{method}``."""
|
||||
url = f"{config.SKLIK_BASE_URL}/{method}"
|
||||
try:
|
||||
resp = await self._http.post(url, json=args)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise UpstreamError(
|
||||
f"Sklik request timed out ({method}).", status=504
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise UpstreamError(
|
||||
f"Sklik is unreachable ({method}): {exc}", status=502
|
||||
) from exc
|
||||
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
raise UpstreamError(
|
||||
f"Sklik returned a non-JSON response ({method}).",
|
||||
status=502,
|
||||
upstream_status=resp.status_code,
|
||||
body={"raw": resp.text},
|
||||
) from exc
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise UpstreamError(
|
||||
f"Unexpected Sklik response shape ({method}).",
|
||||
status=502,
|
||||
body=payload,
|
||||
)
|
||||
|
||||
status = payload.get("status")
|
||||
# Sklik conveys business errors in the body with an HTTP-style status.
|
||||
# 200 OK, 206 partially OK, 301 "user is serviced" are all acceptable.
|
||||
if status not in (200, 206, 301):
|
||||
raise UpstreamError(
|
||||
payload.get("statusMessage", f"Sklik error on {method}."),
|
||||
status=400 if isinstance(status, int) and 400 <= status < 500 else 502,
|
||||
upstream_status=status if isinstance(status, int) else None,
|
||||
body=payload,
|
||||
)
|
||||
|
||||
# Refresh our session from every response (Sklik rotates it).
|
||||
new_session = payload.get("session")
|
||||
if isinstance(new_session, str) and new_session:
|
||||
self._session = new_session
|
||||
return payload
|
||||
|
||||
async def login(self) -> dict:
|
||||
"""Exchange the API token for a session. Idempotent per client."""
|
||||
payload = await self._call("client.loginByToken", [self._token])
|
||||
if not self._session:
|
||||
raise UpstreamError(
|
||||
"Sklik login succeeded but returned no session.",
|
||||
status=502,
|
||||
body=payload,
|
||||
)
|
||||
return payload
|
||||
|
||||
def _user_struct(self) -> dict:
|
||||
user: dict[str, Any] = {"session": self._session}
|
||||
if self._user_id is not None:
|
||||
user["userId"] = self._user_id
|
||||
return user
|
||||
|
||||
async def call(self, method: str, args: list[Any] | None = None) -> dict:
|
||||
"""Authenticated call: prepends the user/session struct to ``args``.
|
||||
|
||||
Logs in first if no session is held yet. ``method`` is e.g.
|
||||
``campaigns.list``; ``args`` are the method arguments AFTER the user
|
||||
struct.
|
||||
"""
|
||||
if method == "client.loginByToken":
|
||||
# Login is handled by login(); never forward the bare token here.
|
||||
raise UpstreamError(
|
||||
"client.loginByToken cannot be called directly; the proxy "
|
||||
"manages the session.",
|
||||
status=400,
|
||||
)
|
||||
if not self._session:
|
||||
await self.login()
|
||||
full_args = [self._user_struct()] + list(args or [])
|
||||
return await self._call(method, full_args)
|
||||
|
||||
async def fetch_report(
|
||||
self, entity: str, report_args: list[Any]
|
||||
) -> dict:
|
||||
"""Create a stats report for ``entity`` then read all of its rows.
|
||||
|
||||
``entity`` is e.g. ``campaigns``/``groups``/``ads``/``keywords``.
|
||||
Calls ``{entity}.createReport`` with ``report_args`` (the restriction +
|
||||
display-options structs), then pages through ``{entity}.readReport``
|
||||
until every row is collected.
|
||||
"""
|
||||
created = await self.call(f"{entity}.createReport", report_args)
|
||||
report_id = created.get("reportId")
|
||||
if not report_id:
|
||||
raise UpstreamError(
|
||||
f"{entity}.createReport returned no reportId.",
|
||||
status=502,
|
||||
body=created,
|
||||
)
|
||||
total = created.get("totalCount", 0)
|
||||
|
||||
rows: list[Any] = []
|
||||
offset = 0
|
||||
pages = 0
|
||||
while True:
|
||||
page = await self.call(
|
||||
f"{entity}.readReport",
|
||||
[
|
||||
report_id,
|
||||
{
|
||||
"offset": offset,
|
||||
"limit": _REPORT_PAGE_LIMIT,
|
||||
"allowEmptyStatistics": False,
|
||||
},
|
||||
],
|
||||
)
|
||||
batch = page.get("report") or []
|
||||
rows.extend(batch)
|
||||
pages += 1
|
||||
offset += _REPORT_PAGE_LIMIT
|
||||
if len(batch) < _REPORT_PAGE_LIMIT:
|
||||
break
|
||||
if pages >= _REPORT_MAX_PAGES:
|
||||
logger.warning(
|
||||
"Sklik %s.readReport hit the %d-page safety cap (collected "
|
||||
"%d rows); result may be truncated.",
|
||||
entity,
|
||||
_REPORT_MAX_PAGES,
|
||||
len(rows),
|
||||
)
|
||||
break
|
||||
|
||||
return {
|
||||
"reportId": report_id,
|
||||
"totalCount": total,
|
||||
"returnedCount": len(rows),
|
||||
"truncated": pages >= _REPORT_MAX_PAGES,
|
||||
"report": rows,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Runtime configuration read from environment variables.
|
||||
|
||||
AppFactory injects variables/secrets as environment variables (see AGENTS.md).
|
||||
This module holds only NON-secret infrastructure configuration. Per-request
|
||||
credentials are never stored here - they arrive in X- headers (see
|
||||
``app.credentials``).
|
||||
"""
|
||||
import os
|
||||
|
||||
# Public app metadata
|
||||
APP_NAME = os.getenv("APP_NAME", "analytics")
|
||||
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
||||
|
||||
# Reverse-proxy prefix injected by AppFactory (e.g. "/apps/analytics").
|
||||
# Empty when running locally at the domain root.
|
||||
ROOT_PATH = os.getenv("ROOT_PATH", "")
|
||||
|
||||
# --- Google Analytics ---------------------------------------------------------
|
||||
# Base URLs are configurable so we can point at a staging/mock endpoint, but
|
||||
# default to the production Google endpoints.
|
||||
GA_DATA_BASE_URL = os.getenv(
|
||||
"GA_DATA_BASE_URL", "https://analyticsdata.googleapis.com/v1beta"
|
||||
)
|
||||
GA_ADMIN_BASE_URL = os.getenv(
|
||||
"GA_ADMIN_BASE_URL", "https://analyticsadmin.googleapis.com/v1beta"
|
||||
)
|
||||
# OAuth scope requested when minting an access token from a service account.
|
||||
# analytics.readonly is sufficient for reporting (Data API) and for listing
|
||||
# accounts/properties/data streams (Admin API read operations).
|
||||
GA_SCOPE = os.getenv(
|
||||
"GA_SCOPE", "https://www.googleapis.com/auth/analytics.readonly"
|
||||
)
|
||||
|
||||
# --- Sklik (Seznam) -----------------------------------------------------------
|
||||
# Sklik "Drak" JSON API. The method name is appended to this base URL and the
|
||||
# HTTP body is a JSON array of positional arguments.
|
||||
SKLIK_BASE_URL = os.getenv("SKLIK_BASE_URL", "https://api.sklik.cz/drak/json/v5")
|
||||
|
||||
# --- HTTP ---------------------------------------------------------------------
|
||||
# Upstream request timeout in seconds.
|
||||
HTTP_TIMEOUT_SECONDS = float(os.getenv("HTTP_TIMEOUT_SECONDS", "60"))
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Per-request credential extraction from X- headers (FastAPI dependencies).
|
||||
|
||||
The service is a STATELESS proxy: it stores no secrets. Every credential is
|
||||
supplied per request as an X- header and used only to talk to the upstream API
|
||||
(see AGENTS.md "Secrets v parametrech"). Declaring the headers as FastAPI
|
||||
``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.
|
||||
|
||||
Sklik:
|
||||
* ``X-Sklik-Token`` - the Sklik API token from account settings. The proxy
|
||||
calls ``client.loginByToken`` to obtain a session.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Header
|
||||
|
||||
from .errors import MissingCredentialsError
|
||||
|
||||
|
||||
# --- Google Analytics ---------------------------------------------------------
|
||||
@dataclass
|
||||
class GaCredentials:
|
||||
access_token: str | None
|
||||
service_account_info: dict | None
|
||||
quota_project: str | None
|
||||
|
||||
|
||||
def get_ga_credentials(
|
||||
x_ga_access_token: str | None = Header(
|
||||
default=None,
|
||||
alias="X-GA-Access-Token",
|
||||
description="Ready OAuth2 access token used directly as a Bearer token. "
|
||||
"Takes precedence over X-GA-Credentials.",
|
||||
),
|
||||
x_ga_credentials: str | None = Header(
|
||||
default=None,
|
||||
alias="X-GA-Credentials",
|
||||
description="Base64-encoded Google service-account JSON key. The proxy "
|
||||
"mints a short-lived access token from it (scope analytics.readonly). "
|
||||
"Used only if X-GA-Access-Token is absent.",
|
||||
),
|
||||
x_ga_quota_project: str | None = Header(
|
||||
default=None,
|
||||
alias="X-GA-Quota-Project",
|
||||
description="Optional Google Cloud project id used 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
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
return GaCredentials(
|
||||
access_token=access_token,
|
||||
service_account_info=service_account_info,
|
||||
quota_project=(x_ga_quota_project or "").strip() or None,
|
||||
)
|
||||
|
||||
|
||||
# --- Sklik --------------------------------------------------------------------
|
||||
def get_sklik_token(
|
||||
x_sklik_token: str | None = Header(
|
||||
default=None,
|
||||
alias="X-Sklik-Token",
|
||||
description="Sklik API token from Sklik account settings. The proxy uses "
|
||||
"it to obtain a session via client.loginByToken.",
|
||||
),
|
||||
) -> str:
|
||||
token = (x_sklik_token or "").strip()
|
||||
if not token:
|
||||
raise MissingCredentialsError("X-Sklik-Token header is required.")
|
||||
return token
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Domain exceptions and FastAPI exception handlers.
|
||||
|
||||
All errors are surfaced as JSON (never swallowed). Upstream failures preserve
|
||||
the upstream status code and body so callers can diagnose problems.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MissingCredentialsError(Exception):
|
||||
"""A required credential header was not supplied."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
class UpstreamError(Exception):
|
||||
"""The upstream API (Google / Sklik) returned an error or was unreachable.
|
||||
|
||||
``status`` is the HTTP status to return to the caller. ``upstream_status``
|
||||
and ``body`` carry the upstream detail when available.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status: int = 502,
|
||||
upstream_status: int | None = None,
|
||||
body: Any = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
self.upstream_status = upstream_status
|
||||
self.body = body
|
||||
|
||||
|
||||
def _problem(status: int, title: str, **extra: Any) -> JSONResponse:
|
||||
payload: dict[str, Any] = {"error": title, "status": status}
|
||||
payload.update({k: v for k, v in extra.items() if v is not None})
|
||||
return JSONResponse(status_code=status, content=payload)
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(MissingCredentialsError)
|
||||
async def _missing_credentials(request: Request, exc: MissingCredentialsError):
|
||||
# Not an error worth a stack trace, but we still log it so missing-header
|
||||
# problems are diagnosable. The credential VALUE is never logged.
|
||||
logger.warning("Missing credentials for %s: %s", request.url.path, exc.message)
|
||||
return _problem(401, "missing_credentials", detail=exc.message)
|
||||
|
||||
@app.exception_handler(UpstreamError)
|
||||
async def _upstream_error(request: Request, exc: UpstreamError):
|
||||
logger.error(
|
||||
"Upstream error on %s: %s (upstream_status=%s)",
|
||||
request.url.path,
|
||||
exc.message,
|
||||
exc.upstream_status,
|
||||
)
|
||||
return _problem(
|
||||
exc.status,
|
||||
"upstream_error",
|
||||
detail=exc.message,
|
||||
upstream_status=exc.upstream_status,
|
||||
upstream_body=exc.body,
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _unhandled(request: Request, exc: Exception):
|
||||
# Last-resort handler: never leak a stack trace to the client, but always
|
||||
# log it server-side so nothing fails silently.
|
||||
logger.exception("Unhandled error on %s", request.url.path)
|
||||
return _problem(500, "internal_error", detail=str(exc))
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Centralized logging setup.
|
||||
|
||||
Project rule (see memory ``no-silent-failures``): every error or unexpected
|
||||
state must reach the log. Never swallow an exception silently. Secrets
|
||||
(tokens, service-account keys, sessions) must NEVER be logged.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
_configured = False
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure root logging once. Safe to call multiple times."""
|
||||
global _configured
|
||||
if _configured:
|
||||
return
|
||||
logging.basicConfig(
|
||||
level=_LEVEL,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
_configured = True
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Return a module logger. Use ``get_logger(__name__)``."""
|
||||
configure_logging()
|
||||
return logging.getLogger(name)
|
||||
+43
-16
@@ -1,25 +1,52 @@
|
||||
"""analytics - stateless API proxy for Google Analytics (GA4) and Sklik.
|
||||
|
||||
Runs behind the AppFactory Caddy reverse proxy at /apps/<app-id>. ROOT_PATH is
|
||||
injected as an env var; FastAPI's ``root_path`` makes Swagger UI and the OpenAPI
|
||||
``servers`` use the proxy prefix so "Try it out" hits /apps/<app-id>/... .
|
||||
|
||||
The service stores no secrets. Every credential is supplied per request in an
|
||||
X- header and used only to talk to the upstream API (see AGENTS.md and
|
||||
``app.credentials``).
|
||||
"""
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
APP_NAME = os.getenv("APP_NAME", "analytics")
|
||||
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
||||
from . import config
|
||||
from .errors import register_exception_handlers
|
||||
from .logging_config import get_logger
|
||||
from .routers import ga_admin, ga_data, meta, sklik
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
ROOT_PATH = os.getenv("ROOT_PATH", "")
|
||||
|
||||
DESCRIPTION = """
|
||||
Stateless proxy exposing **Google Analytics 4** and **Sklik** (Seznam) APIs.
|
||||
|
||||
All credentials are passed per request as `X-` headers (never stored):
|
||||
|
||||
* **Google Analytics** — `X-GA-Access-Token` (preferred) or `X-GA-Credentials`
|
||||
(base64 service-account JSON). Optional `X-GA-Quota-Project`.
|
||||
* **Sklik** — `X-Sklik-Token`. Optional `X-Sklik-User-Id` for managed accounts.
|
||||
|
||||
See `documentation/` in the repository for details.
|
||||
""".strip()
|
||||
|
||||
app = FastAPI(
|
||||
title=APP_NAME,
|
||||
version=APP_VERSION,
|
||||
root_path=ROOT_PATH
|
||||
title=config.APP_NAME,
|
||||
version=config.APP_VERSION,
|
||||
description=DESCRIPTION,
|
||||
root_path=ROOT_PATH,
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
register_exception_handlers(app)
|
||||
|
||||
@app.get("/version")
|
||||
def version():
|
||||
return {
|
||||
"app": APP_NAME,
|
||||
"version": APP_VERSION,
|
||||
"language": "python",
|
||||
"root_path": ROOT_PATH
|
||||
}
|
||||
app.include_router(meta.router)
|
||||
app.include_router(ga_data.router)
|
||||
app.include_router(ga_admin.router)
|
||||
app.include_router(sklik.router)
|
||||
|
||||
logger.info(
|
||||
"analytics started (version=%s, root_path=%r)", config.APP_VERSION, ROOT_PATH
|
||||
)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Google Analytics 4 - Admin API (read: accounts, properties, data streams).
|
||||
|
||||
https://developers.google.com/analytics/devguides/config/admin/v1
|
||||
|
||||
Credentials: X-GA-Access-Token (preferred) or X-GA-Credentials.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from ..clients.ga_client import GoogleAnalyticsClient
|
||||
from ..credentials import GaCredentials, get_ga_credentials
|
||||
|
||||
router = APIRouter(prefix="/ga/admin", tags=["google-analytics: admin"])
|
||||
|
||||
|
||||
def _property(property_id: str) -> str:
|
||||
pid = property_id.strip()
|
||||
return pid if pid.startswith("properties/") else f"properties/{pid}"
|
||||
|
||||
|
||||
@router.get("/accounts", summary="List accessible GA4 accounts")
|
||||
async def list_accounts(
|
||||
page_size: int | None = Query(None, ge=1, le=200, alias="pageSize"),
|
||||
page_token: str | None = Query(None, alias="pageToken"),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
params = _paging(page_size, page_token)
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.admin_get("/accounts", params=params)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/accountSummaries",
|
||||
summary="List account summaries (accounts + their properties)",
|
||||
)
|
||||
async def list_account_summaries(
|
||||
page_size: int | None = Query(None, ge=1, le=200, alias="pageSize"),
|
||||
page_token: str | None = Query(None, alias="pageToken"),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
params = _paging(page_size, page_token)
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.admin_get("/accountSummaries", params=params)
|
||||
|
||||
|
||||
@router.get("/properties", summary="List properties under an account")
|
||||
async def list_properties(
|
||||
account_id: str = Query(
|
||||
...,
|
||||
alias="accountId",
|
||||
description="Numeric account id; the filter parent:accounts/{id} is built for you.",
|
||||
),
|
||||
page_size: int | None = Query(None, ge=1, le=200, alias="pageSize"),
|
||||
page_token: str | None = Query(None, alias="pageToken"),
|
||||
show_deleted: bool | None = Query(None, alias="showDeleted"),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
params: dict[str, Any] = {"filter": f"parent:accounts/{account_id.strip()}"}
|
||||
params.update(_paging(page_size, page_token))
|
||||
if show_deleted is not None:
|
||||
params["showDeleted"] = show_deleted
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.admin_get("/properties", params=params)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/properties/{property_id}",
|
||||
summary="Get a single GA4 property",
|
||||
)
|
||||
async def get_property(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.admin_get(f"/{_property(property_id)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/properties/{property_id}/dataStreams",
|
||||
summary="List data streams of a GA4 property",
|
||||
)
|
||||
async def list_data_streams(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
page_size: int | None = Query(None, ge=1, le=200, alias="pageSize"),
|
||||
page_token: str | None = Query(None, alias="pageToken"),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
params = _paging(page_size, page_token)
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.admin_get(
|
||||
f"/{_property(property_id)}/dataStreams", params=params
|
||||
)
|
||||
|
||||
|
||||
def _paging(page_size: int | None, page_token: str | None) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {}
|
||||
if page_size is not None:
|
||||
params["pageSize"] = page_size
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
return params
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Google Analytics 4 - Data API (reporting).
|
||||
|
||||
Thin passthrough: request bodies are the GA4 Data API request objects and
|
||||
responses are returned as-is. See
|
||||
https://developers.google.com/analytics/devguides/reporting/data/v1/rest
|
||||
|
||||
Credentials: X-GA-Access-Token (preferred) or X-GA-Credentials. See
|
||||
``app.credentials.get_ga_credentials``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path
|
||||
|
||||
from ..clients.ga_client import GoogleAnalyticsClient
|
||||
from ..credentials import GaCredentials, get_ga_credentials
|
||||
|
||||
router = APIRouter(prefix="/ga/data", tags=["google-analytics: data"])
|
||||
|
||||
# Reused OpenAPI example for report request bodies.
|
||||
_RUN_REPORT_EXAMPLE = {
|
||||
"dateRanges": [{"startDate": "7daysAgo", "endDate": "today"}],
|
||||
"dimensions": [{"name": "country"}],
|
||||
"metrics": [{"name": "activeUsers"}],
|
||||
}
|
||||
|
||||
|
||||
def _property(property_id: str) -> str:
|
||||
# Accept both "123456789" and "properties/123456789".
|
||||
pid = property_id.strip()
|
||||
return pid if pid.startswith("properties/") else f"properties/{pid}"
|
||||
|
||||
|
||||
@router.post(
|
||||
"/properties/{property_id}/runReport",
|
||||
summary="Run a GA4 report",
|
||||
)
|
||||
async def run_report(
|
||||
property_id: str = Path(..., description="GA4 property id, e.g. 123456789"),
|
||||
body: dict[str, Any] = Body(..., examples=[_RUN_REPORT_EXAMPLE]),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_post(f"/{_property(property_id)}:runReport", body)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/properties/{property_id}/runPivotReport",
|
||||
summary="Run a GA4 pivot report",
|
||||
)
|
||||
async def run_pivot_report(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
body: dict[str, Any] = Body(...),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_post(
|
||||
f"/{_property(property_id)}:runPivotReport", body
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/properties/{property_id}/batchRunReports",
|
||||
summary="Run up to 5 GA4 reports in one call",
|
||||
)
|
||||
async def batch_run_reports(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
body: dict[str, Any] = Body(...),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_post(
|
||||
f"/{_property(property_id)}:batchRunReports", body
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/properties/{property_id}/batchRunPivotReports",
|
||||
summary="Run up to 5 GA4 pivot reports in one call",
|
||||
)
|
||||
async def batch_run_pivot_reports(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
body: dict[str, Any] = Body(...),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_post(
|
||||
f"/{_property(property_id)}:batchRunPivotReports", body
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/properties/{property_id}/runRealtimeReport",
|
||||
summary="Run a GA4 realtime report",
|
||||
)
|
||||
async def run_realtime_report(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
body: dict[str, Any] = Body(...),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_post(
|
||||
f"/{_property(property_id)}:runRealtimeReport", body
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/properties/{property_id}/checkCompatibility",
|
||||
summary="Check dimension/metric compatibility for a GA4 report",
|
||||
)
|
||||
async def check_compatibility(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
body: dict[str, Any] = Body(...),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_post(
|
||||
f"/{_property(property_id)}:checkCompatibility", body
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/properties/{property_id}/metadata",
|
||||
summary="List available GA4 dimensions and metrics for a property",
|
||||
)
|
||||
async def get_metadata(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_get(f"/{_property(property_id)}/metadata")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Infrastructure endpoints required by AppFactory. No credentials needed."""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .. import config
|
||||
|
||||
router = APIRouter(tags=["meta"])
|
||||
|
||||
|
||||
@router.get("/health", summary="Liveness/readiness probe")
|
||||
def health() -> dict:
|
||||
"""Return 200 while the app can serve traffic. Used by AppFactory monitoring."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/version", summary="Service version and build info")
|
||||
def version() -> dict:
|
||||
return {
|
||||
"app": config.APP_NAME,
|
||||
"version": config.APP_VERSION,
|
||||
"language": "python",
|
||||
"root_path": config.ROOT_PATH,
|
||||
"integrations": ["google-analytics", "sklik"],
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Sklik (Seznam) - JSON-RPC proxy.
|
||||
|
||||
The proxy logs in with X-Sklik-Token per request (client.loginByToken) and then
|
||||
performs the requested call, injecting the session for you. See
|
||||
``app.clients.sklik_client`` and https://api.sklik.cz/drak/ for methods.
|
||||
|
||||
Credentials: X-Sklik-Token (required).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header, Path
|
||||
|
||||
from ..clients.sklik_client import SklikClient
|
||||
from ..credentials import get_sklik_token
|
||||
from ..errors import UpstreamError
|
||||
|
||||
router = APIRouter(prefix="/sklik", tags=["sklik"])
|
||||
|
||||
# Entities that expose createReport/readReport for the report helper.
|
||||
_REPORT_ENTITIES = {
|
||||
"campaigns",
|
||||
"groups",
|
||||
"ads",
|
||||
"keywords",
|
||||
"queries",
|
||||
"sitelinks",
|
||||
"productSets",
|
||||
"banners",
|
||||
}
|
||||
|
||||
_REPORT_EXAMPLE = [
|
||||
{"dateFrom": "2026-06-01", "dateTo": "2026-06-18", "statGranularity": "daily"},
|
||||
{"statGranularity": "daily"},
|
||||
]
|
||||
|
||||
|
||||
def _optional_user_id(
|
||||
x_sklik_user_id: str | None = Header(
|
||||
default=None,
|
||||
alias="X-Sklik-User-Id",
|
||||
description="Optional managed account id (userId) to act on behalf of, "
|
||||
"for agency/MCC access.",
|
||||
),
|
||||
) -> int | None:
|
||||
raw = (x_sklik_user_id or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError as exc:
|
||||
raise UpstreamError(
|
||||
"X-Sklik-User-Id must be an integer.", status=400
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/login", summary="Verify the Sklik token (client.loginByToken)")
|
||||
async def login(
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> dict:
|
||||
"""Check the token works. The session itself is internal and not returned."""
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
payload = await client.login()
|
||||
return {
|
||||
"valid": True,
|
||||
"status": payload.get("status"),
|
||||
"statusMessage": payload.get("statusMessage"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/limits", summary="API limits and quota (api.limits)")
|
||||
async def limits(
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> Any:
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return await client.call("api.limits")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/report/{entity}",
|
||||
summary="Create and read a Sklik stats report (createReport + readReport)",
|
||||
)
|
||||
async def report(
|
||||
entity: str = Path(
|
||||
...,
|
||||
description="Entity to report on: "
|
||||
+ ", ".join(sorted(_REPORT_ENTITIES)),
|
||||
),
|
||||
body: list[Any] = Body(
|
||||
...,
|
||||
examples=[_REPORT_EXAMPLE],
|
||||
description="Arguments for {entity}.createReport (restriction filter and "
|
||||
"optional display options). The session is injected automatically.",
|
||||
),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> Any:
|
||||
if entity not in _REPORT_ENTITIES:
|
||||
raise UpstreamError(
|
||||
f"Unsupported report entity '{entity}'. Allowed: "
|
||||
+ ", ".join(sorted(_REPORT_ENTITIES)),
|
||||
status=400,
|
||||
)
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return await client.fetch_report(entity, body)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rpc/{method}",
|
||||
summary="Generic authenticated Sklik call (any method)",
|
||||
)
|
||||
async def rpc(
|
||||
method: str = Path(
|
||||
...,
|
||||
description="Sklik method name, e.g. campaigns.list, groups.list, "
|
||||
"ads.list, api.limits. (client.loginByToken is managed by the proxy.)",
|
||||
),
|
||||
args: list[Any] = Body(
|
||||
default=[],
|
||||
description="Positional arguments AFTER the session struct (which the "
|
||||
"proxy injects as the first argument). Example for campaigns.list: "
|
||||
'[{"statuses": ["active"]}, {"displayColumns": ["id","name"]}]',
|
||||
),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> Any:
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return await client.call(method, args)
|
||||
Reference in New Issue
Block a user