From a7cb53e0e61b3f15dcb9bf29d7353c3fc8b4c24c Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:36:55 +0200 Subject: [PATCH] first --- README.md | 68 +++++++- app/clients/__init__.py | 0 app/clients/graph.py | 231 +++++++++++++++++++++++++ app/config.py | 57 +++++++ app/credentials.py | 143 ++++++++++++++++ app/errors.py | 85 +++++++++ app/logging_config.py | 30 ++++ app/main.py | 158 +++++++++++++++-- app/routers/__init__.py | 0 app/routers/entities.py | 341 +++++++++++++++++++++++++++++++++++++ app/routers/infra.py | 24 +++ app/routers/insights.py | 325 +++++++++++++++++++++++++++++++++++ app/routers/passthrough.py | 79 +++++++++ documentation/meta-ads.md | 201 ++++++++++++++++++++++ documentation/overview.md | 132 ++++++++++++++ requirements.txt | 1 + 16 files changed, 1856 insertions(+), 19 deletions(-) create mode 100644 app/clients/__init__.py create mode 100644 app/clients/graph.py create mode 100644 app/config.py create mode 100644 app/credentials.py create mode 100644 app/errors.py create mode 100644 app/logging_config.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/entities.py create mode 100644 app/routers/infra.py create mode 100644 app/routers/insights.py create mode 100644 app/routers/passthrough.py create mode 100644 documentation/meta-ads.md create mode 100644 documentation/overview.md diff --git a/README.md b/README.md index 647970a..9940356 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,69 @@ # Meta services -Generated by AppFactory. +Stateless API proxy over the **Meta Marketing API** (Facebook / Instagram +advertising), running in AppFactory at `https://services.csbot.cz/apps/meta`. + +The service stores no secrets: every credential is supplied per request in an +`X-` header and used only to call the Graph API. Structure mirrors the sibling +`analytics` service. + +**Phase 1 is read-only** — every endpoint issues a GET upstream, so the service +cannot spend money. Campaign management is phase 2. + +## Quick start + +``` +GET https://services.csbot.cz/apps/meta/health +GET https://services.csbot.cz/apps/meta/docs ← Swagger, "Try it out" +``` + +```bash +curl "https://services.csbot.cz/apps/meta/ads/me/adaccounts" \ + -H "X-Meta-Access-Token: EAAG..." \ + -H "X-Meta-App-Secret: " +``` + +## Credentials + +| Header | Required | Meaning | +| --- | --- | --- | +| `X-Meta-Access-Token` | one of these | Access token; a Business Manager **System User** token is recommended. | +| `Authorization: Bearer ` | one of these | Equivalent alternative (the X- header wins if both are sent). | +| `X-Meta-App-Secret` | no, recommended | App secret → the proxy derives `appsecret_proof`. Required if the app enforces app-secret proof. | +| `X-Meta-Api-Version` | no | Graph version override, e.g. `v25.0`. | + +Permissions needed for this phase: `ads_read`, `business_management`. + +## Endpoints + +| Group | Paths | +| --- | --- | +| Infra | `/health`, `/version` | +| Structure | `/ads/me/adaccounts`, `/ads/me/businesses`, `/ads/businesses/{id}/adaccounts`, `/ads/accounts/{id}` (+ `/campaigns`, `/adsets`, `/ads`, `/adcreatives`), `/ads/campaigns/{id}` (+ `/adsets`, `/ads`), `/ads/adsets/{id}` (+ `/ads`), `/ads/ads/{id}`, `/ads/adcreatives/{id}` | +| Insights | `/ads/insights/{object_id}`, `/ads/insights/{object_id}/jobs`, `/ads/insights/{object_id}/run`, `/ads/insights/jobs/{report_run_id}`, `/ads/insights/jobs/{report_run_id}/results` | +| Generic read | `/graph/{path}` | + +Large reports must go through the async endpoints — see +[documentation/meta-ads.md](documentation/meta-ads.md). + +## Configuration + +Non-secret settings come from environment variables; see the table in +[documentation/overview.md](documentation/overview.md). The most relevant one is +`META_API_VERSION` (default `v25.0`), which callers can override per request. + +## Local development + +```bash +python -m venv .venv +.venv/Scripts/pip install -r requirements.txt +.venv/Scripts/uvicorn app.main:app --reload --port 8000 +# http://127.0.0.1:8000/docs +``` + +## Documentation + +- [documentation/overview.md](documentation/overview.md) — architecture, config, + design decisions, what is deliberately not wired. +- [documentation/meta-ads.md](documentation/meta-ads.md) — endpoints, insight + fields and levels, pagination, errors, curl examples. diff --git a/app/clients/__init__.py b/app/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/clients/graph.py b/app/clients/graph.py new file mode 100644 index 0000000..2c11634 --- /dev/null +++ b/app/clients/graph.py @@ -0,0 +1,231 @@ +"""HTTP client for the Meta Graph / Marketing API. + +Deliberately hand-rolled over ``httpx`` rather than using the official +``facebook-business`` SDK: this service is a thin passthrough, the SDK is +synchronous and imposes its own object model and error types that we would only +have to translate back into our JSON shape. Staying on raw HTTP also means a new +Graph version is a config change, not a dependency bump. + +What this module adds on top of a plain request: + + * auth - the access token as a Bearer header (never a query param, so tokens + do not end up in upstream access logs), plus ``appsecret_proof`` when an app + secret was supplied; + * versioned URL building from the per-request Graph version; + * cursor pagination with an explicit page cap; + * error mapping into ``UpstreamError``; + * capture of Meta's rate-limit headers so callers can pace themselves. + +Request and response bodies are otherwise forwarded as-is, so callers keep the +full upstream API surface. +""" +from __future__ import annotations + +import json +from contextvars import ContextVar +from typing import Any + +import httpx + +from .. import config +from ..credentials import MetaCredentials +from ..errors import UpstreamError +from ..logging_config import get_logger + +logger = get_logger(__name__) + +# Meta reports quota consumption in these response headers. A middleware in +# main.py installs an empty dict per request and copies whatever lands in it +# onto our own response, so callers can see how close to a throttle they are +# without us threading a Response object through every route. +# +# The middleware must create the dict and we only ever MUTATE it: with +# Starlette's BaseHTTPMiddleware the endpoint runs in a child task that gets a +# *copy* of the context, so a `.set()` here would not be visible to the +# middleware - mutating the shared dict is. +USAGE_HEADERS = ( + "X-App-Usage", + "X-Ad-Account-Usage", + "X-Business-Use-Case-Usage", +) + +current_usage: ContextVar[dict[str, str] | None] = ContextVar( + "meta_usage", default=None +) + + +def _record_usage(resp: httpx.Response) -> None: + sink = current_usage.get() + if sink is None: + return + for header in USAGE_HEADERS: + if header in resp.headers: + sink[header] = resp.headers[header] + + +def _encode_form(data: dict[str, Any]) -> dict[str, str]: + """Form-encode a body for Graph writes. + + The Graph API expects form fields; anything structured (lists, dicts) has to + be a JSON string inside that form field, not a nested form structure. + """ + encoded: dict[str, str] = {} + for key, value in data.items(): + if value is None: + continue + if isinstance(value, (dict, list)): + encoded[key] = json.dumps(value, ensure_ascii=False) + elif isinstance(value, bool): + encoded[key] = "true" if value else "false" + else: + encoded[key] = str(value) + return encoded + + +class MetaGraphClient: + """Authenticated client for one Graph API request cycle.""" + + def __init__( + self, + creds: MetaCredentials, + *, + service_name: str = "Meta Graph API", + ) -> None: + self._creds = creds + self._service_name = service_name + + # --- URL / params --------------------------------------------------------- + def url(self, path: str) -> str: + """Build a versioned Graph URL from a path like 'act_123/campaigns'.""" + return f"{config.META_GRAPH_BASE_URL}/{self._creds.api_version}/{path.lstrip('/')}" + + def build_url(self, url: str | httpx.URL, params: dict[str, Any] | None) -> httpx.URL: + """Merge params and appsecret_proof INTO the URL's existing query. + + Merging rather than passing httpx's ``params=`` matters: that argument + replaces the whole query string, which would silently strip the cursor + out of a ``paging.next`` URL and make pagination read page 1 forever. + """ + target = httpx.URL(url) + if params: + clean = {k: v for k, v in params.items() if v is not None} + if clean: + target = target.copy_merge_params(clean) + proof = self._creds.appsecret_proof() + if proof: + target = target.copy_merge_params({"appsecret_proof": proof}) + return target + + # --- requests ------------------------------------------------------------- + async def request( + self, + method: str, + url: str | httpx.URL, + *, + params: dict[str, Any] | None = None, + form: dict[str, Any] | None = None, + ) -> Any: + headers = {"Authorization": f"Bearer {self._creds.access_token}"} + target = self.build_url(url, params) + try: + async with httpx.AsyncClient(timeout=config.HTTP_TIMEOUT_SECONDS) as client: + resp = await client.request( + method, + target, + data=_encode_form(form) if form is not None else None, + headers=headers, + ) + except httpx.TimeoutException as exc: + raise UpstreamError( + f"{self._service_name} request timed out.", status=504 + ) from exc + except httpx.HTTPError as exc: + raise UpstreamError( + f"{self._service_name} is unreachable: {exc}", status=502 + ) from exc + + _record_usage(resp) + return _parse_response(resp, self._service_name) + + async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: + return await self.request("GET", self.url(path), params=params) + + async def post( + self, + path: str, + form: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> Any: + return await self.request("POST", self.url(path), params=params, form=form or {}) + + async def get_absolute(self, url: str) -> Any: + """GET an already-built absolute URL (used to follow paging.next).""" + return await self.request("GET", url) + + # --- pagination ----------------------------------------------------------- + async def get_all_pages( + self, + path: str, + params: dict[str, Any] | None = None, + *, + max_pages: int | None = None, + ) -> dict[str, Any]: + """Follow ``paging.next`` and concatenate ``data`` across pages. + + Stops at ``META_MAX_PAGES`` and marks the result ``truncated: true`` + rather than looping unbounded or silently returning a partial list that + looks complete. + """ + cap = max_pages or config.META_MAX_PAGES + collected: list[Any] = [] + page = await self.get(path, params) + pages_read = 1 + + while True: + if not isinstance(page, dict): + # Non-collection response - hand it back untouched. + return page + collected.extend(page.get("data") or []) + next_url = (page.get("paging") or {}).get("next") + if not next_url: + return {"data": collected, "pages_read": pages_read, "truncated": False} + if pages_read >= cap: + logger.warning( + "Paging cap reached for %s after %s pages; result truncated.", + path, + pages_read, + ) + return { + "data": collected, + "pages_read": pages_read, + "truncated": True, + "next": next_url, + } + page = await self.get_absolute(next_url) + pages_read += 1 + + +def _parse_response(resp: httpx.Response, service_name: str) -> Any: + try: + payload = resp.json() + except ValueError: + payload = {"raw": resp.text} + + if resp.is_success: + return payload + + # Graph errors look like {"error": {"message", "type", "code", + # "error_subcode", "error_user_title", "error_user_msg", "fbtrace_id"}}. + message = f"{service_name} error" + if isinstance(payload, dict): + err = payload.get("error") + if isinstance(err, dict): + # error_user_msg is the human-readable variant when Meta has one. + message = err.get("error_user_msg") or err.get("message") or message + + raise UpstreamError( + message, + status=502 if resp.status_code >= 500 else resp.status_code, + upstream_status=resp.status_code, + body=payload, + ) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..45d9e66 --- /dev/null +++ b/app/config.py @@ -0,0 +1,57 @@ +"""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", "Meta services") +APP_VERSION = os.getenv("APP_VERSION", "1.0.0") + +# Reverse-proxy prefix injected by AppFactory (e.g. "/apps/meta"). +# Empty when running locally at the domain root. +ROOT_PATH = os.getenv("ROOT_PATH", "") + +# --- Meta Graph / Marketing API ---------------------------------------------- +# Base URL is configurable so we can point at a staging/mock endpoint, but +# defaults to the production Graph endpoint. +META_GRAPH_BASE_URL = os.getenv("META_GRAPH_BASE_URL", "https://graph.facebook.com") + +# Default Graph API version. Meta ships a new version every few months and +# deprecates old ones after ~2 years, so the version lives in an env var (same +# reasoning as GOOGLE_ADS_API_VERSION in the sibling `analytics` service). +# Callers may additionally override it per request via X-Meta-Api-Version. +META_API_VERSION = os.getenv("META_API_VERSION", "v25.0") + +# Optional comma-separated allowlist of versions accepted in X-Meta-Api-Version. +# Empty (default) = accept any well-formed "vNN.N" value, so upgrading the Graph +# version never needs a deploy on our side. Set it to lock the service down to +# versions that have actually been tested. +META_ALLOWED_API_VERSIONS = tuple( + v.strip() + for v in os.getenv("META_ALLOWED_API_VERSIONS", "").split(",") + if v.strip() +) + +# Safety cap for cursor-paged reads. Auto-paging is the default (all_pages), so +# this is the backstop that keeps a runaway edge from looping forever. Mirrors +# the Sklik report page cap in `analytics`: a truncated result says so +# explicitly instead of silently looking complete. +# Graph's own page size defaults to 25, so 100 pages ~ 2500 rows; pass a larger +# `limit` to cover more rows in fewer round trips. +META_MAX_PAGES = int(os.getenv("META_MAX_PAGES", "100")) + +# --- Async insights jobs ------------------------------------------------------ +# Large insights reports are run as async jobs on Meta's side (start job -> +# poll -> read results). These bound the convenience "run and wait" endpoint. +META_ASYNC_POLL_INTERVAL_SECONDS = float( + os.getenv("META_ASYNC_POLL_INTERVAL_SECONDS", "2") +) +META_ASYNC_MAX_WAIT_SECONDS = float(os.getenv("META_ASYNC_MAX_WAIT_SECONDS", "120")) + +# --- HTTP --------------------------------------------------------------------- +# Upstream request timeout in seconds. +HTTP_TIMEOUT_SECONDS = float(os.getenv("HTTP_TIMEOUT_SECONDS", "60")) diff --git a/app/credentials.py b/app/credentials.py new file mode 100644 index 0000000..92bb220 --- /dev/null +++ b/app/credentials.py @@ -0,0 +1,143 @@ +"""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 Meta Graph 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. + +Headers: + + * ``X-Meta-Access-Token`` - required. A Business Manager **System User** + token is the recommended kind: it does not die when an employee leaves and + (when generated without an expiry) does not need refreshing. A user OAuth + token works identically here - refreshing it stays on the caller's side. + As an equivalent alternative the token may arrive in the standard + ``Authorization: Bearer `` header; ``X-Meta-Access-Token`` wins if + both are present. + * ``X-Meta-App-Secret`` - optional but strongly recommended. When present + the proxy computes ``appsecret_proof`` (HMAC-SHA256 of the access token, + keyed with the app secret) and sends it upstream. Meta requires this for + server-side calls whenever the app has "Require app secret proof for server + API calls" enabled; without it those calls fail with OAuth error 100. + * ``X-Meta-Api-Version`` - optional per-request Graph version override + (e.g. ``v25.0``), so bumping the Graph version needs no deploy here. +""" +from __future__ import annotations + +import hashlib +import hmac +import re +from dataclasses import dataclass + +from fastapi import Header + +from . import config +from .errors import MissingCredentialsError + +# Graph versions look like "v25.0". Validated so a typo fails fast here with a +# clear message instead of producing a 404 from a nonsense upstream URL. +_VERSION_RE = re.compile(r"^v\d+\.\d+$") + + +@dataclass +class MetaCredentials: + access_token: str + app_secret: str | None + api_version: str + + def appsecret_proof(self) -> str | None: + """HMAC-SHA256 of the access token keyed with the app secret. + + Returns None when no app secret was supplied. The proof is derived per + request and never cached or logged. + """ + if not self.app_secret: + return None + return hmac.new( + self.app_secret.encode("utf-8"), + self.access_token.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +def _bearer_from_authorization(authorization: str | None) -> str | None: + """Extract the token from a standard ``Authorization: Bearer `` header. + + Only the ``Bearer`` scheme is accepted; any other scheme (e.g. ``Basic``) is + ignored so the caller falls through and gets a clear "no credentials" error + rather than a token that cannot work. + """ + if not authorization: + return None + parts = authorization.strip().split(None, 1) + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1].strip() or None + return None + + +def _resolve_api_version(raw: str | None) -> str: + """Pick the Graph version for this request: header override, else default.""" + version = (raw or "").strip() + if not version: + return config.META_API_VERSION + + # Accept "25.0" as well as "v25.0" - the leading v is easy to forget. + if not version.startswith("v"): + version = f"v{version}" + + if not _VERSION_RE.match(version): + raise MissingCredentialsError( + f"X-Meta-Api-Version '{version}' is not a valid Graph API version " + "(expected e.g. 'v25.0')." + ) + if config.META_ALLOWED_API_VERSIONS and version not in config.META_ALLOWED_API_VERSIONS: + raise MissingCredentialsError( + f"X-Meta-Api-Version '{version}' is not allowed. Allowed versions: " + + ", ".join(config.META_ALLOWED_API_VERSIONS) + ) + return version + + +def get_meta_credentials( + x_meta_access_token: str | None = Header( + default=None, + alias="X-Meta-Access-Token", + description="Meta access token. A Business Manager System User token is " + "recommended (long-lived, survives staff changes). Takes precedence over " + "the Authorization header.", + ), + authorization: str | None = Header( + default=None, + alias="Authorization", + description="Standard bearer token, sent as 'Authorization: Bearer " + "'. Equivalent alternative to X-Meta-Access-Token, which wins if " + "both are present.", + ), + x_meta_app_secret: str | None = Header( + default=None, + alias="X-Meta-App-Secret", + description="Optional Meta app secret. When supplied the proxy computes " + "and sends appsecret_proof, which Meta requires if the app has 'Require " + "app secret proof for server API calls' enabled.", + ), + x_meta_api_version: str | None = Header( + default=None, + alias="X-Meta-Api-Version", + description=f"Optional Graph API version override, e.g. 'v25.0'. " + f"Defaults to {config.META_API_VERSION}.", + ), +) -> MetaCredentials: + token = (x_meta_access_token or "").strip() or None + if token is None: + token = _bearer_from_authorization(authorization) + if not token: + raise MissingCredentialsError( + "Provide X-Meta-Access-Token or an 'Authorization: Bearer ' header." + ) + + return MetaCredentials( + access_token=token, + app_secret=(x_meta_app_secret or "").strip() or None, + api_version=_resolve_api_version(x_meta_api_version), + ) diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..7e1da7a --- /dev/null +++ b/app/errors.py @@ -0,0 +1,85 @@ +"""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. + +Shape matches the sibling `analytics` service so both proxies fail the same way. +""" +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 (or is malformed).""" + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +class UpstreamError(Exception): + """The Meta Graph API 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)) diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 0000000..1cf2f89 --- /dev/null +++ b/app/logging_config.py @@ -0,0 +1,30 @@ +"""Centralized logging setup. + +Project rule: every error or unexpected state must reach the log. Never swallow +an exception silently. Secrets (access tokens, app secrets, appsecret_proof) +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) diff --git a/app/main.py b/app/main.py index fee9390..6e3449e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,25 +1,147 @@ -import os -from fastapi import FastAPI +"""meta - stateless API proxy for the Meta Marketing API (Facebook/Instagram). + +Runs behind the AppFactory Caddy reverse proxy at /apps/. 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/meta/... . + +The service stores no secrets. Every credential is supplied per request in an +X- header and used only to talk to the Graph API (see AGENTS.md and +``app.credentials``). Phase 1 is read-only. +""" +import os + +from fastapi import FastAPI, Request + +from . import config +from .clients.graph import current_usage +from .errors import register_exception_handlers +from .logging_config import get_logger +from .routers import entities, infra, insights, passthrough + +logger = get_logger(__name__) -APP_NAME = os.getenv("APP_NAME", "Meta services") -APP_VERSION = os.getenv("APP_VERSION", "1.0.0") ROOT_PATH = os.getenv("ROOT_PATH", "") +DESCRIPTION = f""" +Stateless proxy nad **Meta Marketing API** (Facebook / Instagram reklamy). + +Veškeré přihlašovací údaje se posílají v každém requestu jako `X-` hlavičky – +služba si nic neukládá. Vyplníte je v Swaggeru po kliknutí na **Try it out**. + +| Hlavička | Povinné | Význam | +| --- | --- | --- | +| `X-Meta-Access-Token` | ano* | Access token (doporučen System User token z Business Manageru). | +| `Authorization: Bearer ` | ano* | Rovnocenná alternativa k `X-Meta-Access-Token`. | +| `X-Meta-App-Secret` | ne, ale doporučeno | App secret – proxy z něj dopočítá `appsecret_proof`. | +| `X-Meta-Api-Version` | ne | Přepsání verze Graph API pro daný request, např. `v25.0`. | + +\\* Token je povinný; pošlete ho buď v `X-Meta-Access-Token`, nebo ve standardní +hlavičce `Authorization: Bearer`. Pokud pošlete obě, vyhrává `X-Meta-Access-Token`. + +--- + +## Verze Graph API + +Výchozí verze je **{config.META_API_VERSION}** (env `META_API_VERSION`). +Jednotlivý request ji může přepsat hlavičkou `X-Meta-Api-Version` – upgrade na +novou verzi Graphu tedy nevyžaduje deploy. Formát se validuje (`vNN.N`); +volitelně lze službu zamknout na seznam ověřených verzí přes +`META_ALLOWED_API_VERSIONS`. + +## Kde vzít přihlašovací údaje + +### 🔹 Access token – System User (doporučeno) + +1. [Business Manager](https://business.facebook.com/) → **Nastavení firmy → + Uživatelé → Systémoví uživatelé**. +2. **Přidat** systémového uživatele, role *Admin* nebo *Zaměstnanec*. +3. **Přidat aktiva** → vyberte reklamní účty, se kterými má pracovat, a udělte + mu na nich oprávnění *Správa kampaní*. +4. **Vygenerovat nový token** → vyberte aplikaci a oprávnění (viz níže). + Token **negenerujte s expirací**, pokud chcete trvalou platnost. + +> 💡 System User token nepřestane fungovat, když někdo odejde z firmy nebo si +> změní heslo – na rozdíl od uživatelského OAuth tokenu. Proto je pro +> server-to-server integraci vhodnější. + +Potřebná oprávnění (scopes) pro tuto fázi (jen čtení): +`ads_read`, `business_management`. + +### 🔹 App secret (`X-Meta-App-Secret`) + +[developers.facebook.com](https://developers.facebook.com/apps/) → vaše +aplikace → **Nastavení → Základní → App Secret**. + +Pokud má aplikace zapnuté *Require app secret proof for server API calls* +(Nastavení → Pokročilé), **je tato hlavička nutná** – bez ní Meta volání odmítne +s chybou OAuth 100. Proxy z tokenu a app secretu spočítá `appsecret_proof` +(HMAC-SHA256) při každém requestu; nikam ho neukládá ani neloguje. + +### 🔹 ID reklamního účtu + +Business Manager → **Nastavení firmy → Reklamní účty**, nebo v Ads Manageru +vlevo nahoře. Číslo lze posílat s prefixem i bez (`act_123456789` i `123456789`). + +--- + +## Limity a stránkování + +- Meta hlásí vyčerpání kvóty v hlavičkách `X-App-Usage`, + `X-Ad-Account-Usage` a `X-Business-Use-Case-Usage`. Proxy je **propisuje zpět** + do své odpovědi, takže si podle nich můžete řídit tempo volání. +- Seznamy jsou stránkované kurzorem, ale **proxy je ve výchozím stavu projde za + vás** (`all_pages=true`) a vrátí kompletní seznam – nemusíte řešit kurzory. + Strop je `META_MAX_PAGES`; při jeho dosažení je v odpovědi `truncated: true`. + Graph vrací 25 položek na stránku, takže větším `limit` ušetříte volání. + S `all_pages=false` dostanete jednu syrovou stránku včetně `paging.cursors`. + +## Velké reporty = asynchronně + +Rozsáhlé insights (dlouhé období, hodně breakdownů, celý účet na úrovni +reklam) Meta počítá **asynchronně**. Použijte +`POST /ads/insights/{{object_id}}/run`, který úlohu založí, počká na dokončení +a vrátí data. Když se nestihne do limitu, dostanete `report_run_id` a doptáte se +přes `/ads/insights/jobs/{{report_run_id}}`. + +--- + +Tato fáze je **jen pro čtení**. Zakládání a úpravy kampaní (fáze 2) záměrně +nejsou zapojené – detaily v `documentation/` v repozitáři. +""".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.middleware("http") +async def _propagate_usage_headers(request: Request, call_next): + """Echo Meta's rate-limit headers back to the caller. + + An empty dict is installed here and mutated by the Graph client (see + ``app.clients.graph``); whatever it collected is copied onto the response. + """ + sink: dict[str, str] = {} + current_usage.set(sink) + response = await call_next(request) + for header, value in sink.items(): + response.headers[header] = value + return response + + +app.include_router(infra.router) +app.include_router(entities.router) +app.include_router(insights.router) +app.include_router(passthrough.router) + +logger.info( + "meta started (version=%s, root_path=%r, graph_api_version=%s)", + config.APP_VERSION, + ROOT_PATH, + config.META_API_VERSION, +) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/entities.py b/app/routers/entities.py new file mode 100644 index 0000000..326a976 --- /dev/null +++ b/app/routers/entities.py @@ -0,0 +1,341 @@ +"""Meta Marketing API - reading the ad account structure. + +Ad accounts, campaigns, ad sets, ads and creatives. All read-only: this module +issues GETs only. Creating or updating entities is deliberately not wired yet +(see documentation/overview.md, "Deliberately not wired"). + +Every list endpoint takes the same three knobs: + + * ``fields`` - Graph field list; each endpoint has a useful default so a + caller who does not know the Graph schema still gets meaningful data; + * ``limit`` / ``after`` - page size and cursor; + * ``all_pages`` - **on by default**: the proxy follows ``paging.next`` so the + caller gets the complete list without looping. Capped by ``META_MAX_PAGES`` + and flagged ``truncated`` if the cap is hit. Set it to false for a single + raw page with the upstream cursors. + +Credentials: see ``app.credentials.get_meta_credentials``. +""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Path, Query + +from ..clients.graph import MetaGraphClient +from ..credentials import MetaCredentials, get_meta_credentials + +router = APIRouter(prefix="/ads", tags=["meta-ads: entities"]) + +# Field defaults. Kept modest on purpose - asking Graph for every field on a +# large account is slow and often trips per-field permission errors. +_ACCOUNT_FIELDS = ( + "id,account_id,name,account_status,currency,timezone_name,business," + "amount_spent,balance,spend_cap" +) +_CAMPAIGN_FIELDS = ( + "id,name,status,effective_status,objective,buying_type,daily_budget," + "lifetime_budget,budget_remaining,start_time,stop_time,created_time,updated_time" +) +_ADSET_FIELDS = ( + "id,name,status,effective_status,campaign_id,daily_budget,lifetime_budget," + "billing_event,optimization_goal,bid_amount,targeting,start_time,end_time," + "created_time,updated_time" +) +_AD_FIELDS = ( + "id,name,status,effective_status,adset_id,campaign_id,creative," + "created_time,updated_time" +) +_CREATIVE_FIELDS = ( + "id,name,status,object_story_spec,asset_feed_spec,thumbnail_url,image_url," + "body,title,call_to_action_type,effective_object_story_id" +) + + +# Shared so every list endpoint documents pagination identically. +_Q_ALL_PAGES = Query( + True, + description="Follow paging.next and return every page (default). The result " + "carries pages_read and truncated; truncated:true means the META_MAX_PAGES " + "cap was hit. Set false to get a single raw page with upstream cursors.", +) + + +def _client(creds: MetaCredentials) -> MetaGraphClient: + return MetaGraphClient(creds, service_name="Meta Marketing API") + + +def _account(account_id: str) -> str: + """Normalize an ad account id: both '123' and 'act_123' are accepted.""" + value = account_id.strip() + return value if value.startswith("act_") else f"act_{value}" + + +async def _read( + creds: MetaCredentials, + path: str, + *, + fields: str | None, + limit: int | None = None, + after: str | None = None, + all_pages: bool = False, + extra: dict[str, Any] | None = None, +) -> Any: + params: dict[str, Any] = {"fields": fields, "limit": limit, "after": after} + if extra: + params.update(extra) + client = _client(creds) + if all_pages: + return await client.get_all_pages(path, params) + return await client.get(path, params) + + +# --- Ad accounts -------------------------------------------------------------- +@router.get("/me/adaccounts", summary="Ad accounts the token can access") +async def my_ad_accounts( + fields: str = Query(_ACCOUNT_FIELDS, description="Comma-separated Graph fields."), + limit: int | None = Query(None, ge=1, le=500), + after: str | None = Query(None, description="Paging cursor from paging.cursors.after."), + all_pages: bool = _Q_ALL_PAGES, + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read( + creds, "me/adaccounts", fields=fields, limit=limit, after=after, all_pages=all_pages + ) + + +@router.get("/me/businesses", summary="Business Manager accounts the token can access") +async def my_businesses( + fields: str = Query("id,name,created_time", description="Comma-separated Graph fields."), + limit: int | None = Query(None, ge=1, le=500), + after: str | None = Query(None), + all_pages: bool = _Q_ALL_PAGES, + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read( + creds, "me/businesses", fields=fields, limit=limit, after=after, all_pages=all_pages + ) + + +@router.get( + "/businesses/{business_id}/adaccounts", + summary="Ad accounts owned by (or shared with) a business", +) +async def business_ad_accounts( + business_id: str = Path(..., description="Business Manager id."), + owned: bool = Query( + True, + description="True = owned_ad_accounts (accounts the business owns); " + "False = client_ad_accounts (accounts shared with it, agency case).", + ), + fields: str = Query(_ACCOUNT_FIELDS), + limit: int | None = Query(None, ge=1, le=500), + after: str | None = Query(None), + all_pages: bool = _Q_ALL_PAGES, + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + edge = "owned_ad_accounts" if owned else "client_ad_accounts" + return await _read( + creds, + f"{business_id.strip()}/{edge}", + fields=fields, + limit=limit, + after=after, + all_pages=all_pages, + ) + + +@router.get("/accounts/{account_id}", summary="Ad account detail") +async def ad_account( + account_id: str = Path(..., description="Ad account id, with or without the act_ prefix."), + fields: str = Query(_ACCOUNT_FIELDS), + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read(creds, _account(account_id), fields=fields) + + +# --- Campaigns ---------------------------------------------------------------- +@router.get("/accounts/{account_id}/campaigns", summary="Campaigns in an ad account") +async def account_campaigns( + account_id: str = Path(..., description="Ad account id, with or without act_."), + fields: str = Query(_CAMPAIGN_FIELDS), + effective_status: str | None = Query( + None, + description='Optional JSON array of statuses to keep, e.g. ["ACTIVE","PAUSED"].', + ), + limit: int | None = Query(None, ge=1, le=500), + after: str | None = Query(None), + all_pages: bool = _Q_ALL_PAGES, + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read( + creds, + f"{_account(account_id)}/campaigns", + fields=fields, + limit=limit, + after=after, + all_pages=all_pages, + extra={"effective_status": effective_status}, + ) + + +@router.get("/campaigns/{campaign_id}", summary="Campaign detail") +async def campaign( + campaign_id: str = Path(...), + fields: str = Query(_CAMPAIGN_FIELDS), + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read(creds, campaign_id.strip(), fields=fields) + + +@router.get("/campaigns/{campaign_id}/adsets", summary="Ad sets in a campaign") +async def campaign_adsets( + campaign_id: str = Path(...), + fields: str = Query(_ADSET_FIELDS), + limit: int | None = Query(None, ge=1, le=500), + after: str | None = Query(None), + all_pages: bool = _Q_ALL_PAGES, + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read( + creds, + f"{campaign_id.strip()}/adsets", + fields=fields, + limit=limit, + after=after, + all_pages=all_pages, + ) + + +@router.get("/campaigns/{campaign_id}/ads", summary="Ads in a campaign") +async def campaign_ads( + campaign_id: str = Path(...), + fields: str = Query(_AD_FIELDS), + limit: int | None = Query(None, ge=1, le=500), + after: str | None = Query(None), + all_pages: bool = _Q_ALL_PAGES, + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read( + creds, + f"{campaign_id.strip()}/ads", + fields=fields, + limit=limit, + after=after, + all_pages=all_pages, + ) + + +# --- Ad sets ------------------------------------------------------------------ +@router.get("/accounts/{account_id}/adsets", summary="Ad sets in an ad account") +async def account_adsets( + account_id: str = Path(...), + fields: str = Query(_ADSET_FIELDS), + effective_status: str | None = Query( + None, description='Optional JSON array, e.g. ["ACTIVE"].' + ), + limit: int | None = Query(None, ge=1, le=500), + after: str | None = Query(None), + all_pages: bool = _Q_ALL_PAGES, + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read( + creds, + f"{_account(account_id)}/adsets", + fields=fields, + limit=limit, + after=after, + all_pages=all_pages, + extra={"effective_status": effective_status}, + ) + + +@router.get("/adsets/{adset_id}", summary="Ad set detail") +async def adset( + adset_id: str = Path(...), + fields: str = Query(_ADSET_FIELDS), + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read(creds, adset_id.strip(), fields=fields) + + +@router.get("/adsets/{adset_id}/ads", summary="Ads in an ad set") +async def adset_ads( + adset_id: str = Path(...), + fields: str = Query(_AD_FIELDS), + limit: int | None = Query(None, ge=1, le=500), + after: str | None = Query(None), + all_pages: bool = _Q_ALL_PAGES, + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read( + creds, + f"{adset_id.strip()}/ads", + fields=fields, + limit=limit, + after=after, + all_pages=all_pages, + ) + + +# --- Ads and creatives -------------------------------------------------------- +@router.get("/accounts/{account_id}/ads", summary="Ads in an ad account") +async def account_ads( + account_id: str = Path(...), + fields: str = Query(_AD_FIELDS), + effective_status: str | None = Query( + None, description='Optional JSON array, e.g. ["ACTIVE"].' + ), + limit: int | None = Query(None, ge=1, le=500), + after: str | None = Query(None), + all_pages: bool = _Q_ALL_PAGES, + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read( + creds, + f"{_account(account_id)}/ads", + fields=fields, + limit=limit, + after=after, + all_pages=all_pages, + extra={"effective_status": effective_status}, + ) + + +@router.get("/ads/{ad_id}", summary="Ad detail") +async def ad( + ad_id: str = Path(...), + fields: str = Query(_AD_FIELDS), + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read(creds, ad_id.strip(), fields=fields) + + +@router.get( + "/accounts/{account_id}/adcreatives", summary="Ad creatives in an ad account" +) +async def account_creatives( + account_id: str = Path(...), + fields: str = Query(_CREATIVE_FIELDS), + limit: int | None = Query(None, ge=1, le=500), + after: str | None = Query(None), + all_pages: bool = _Q_ALL_PAGES, + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read( + creds, + f"{_account(account_id)}/adcreatives", + fields=fields, + limit=limit, + after=after, + all_pages=all_pages, + ) + + +@router.get("/adcreatives/{creative_id}", summary="Ad creative detail") +async def creative( + creative_id: str = Path(...), + fields: str = Query(_CREATIVE_FIELDS), + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _read(creds, creative_id.strip(), fields=fields) diff --git a/app/routers/infra.py b/app/routers/infra.py new file mode 100644 index 0000000..639f124 --- /dev/null +++ b/app/routers/infra.py @@ -0,0 +1,24 @@ +"""Infrastructure endpoints required by AppFactory. No credentials needed.""" +from fastapi import APIRouter + +from .. import config + +router = APIRouter(tags=["infra"]) + + +@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": ["meta-marketing-api"], + "graph_api_version": config.META_API_VERSION, + } diff --git a/app/routers/insights.py b/app/routers/insights.py new file mode 100644 index 0000000..9ba640f --- /dev/null +++ b/app/routers/insights.py @@ -0,0 +1,325 @@ +"""Meta Marketing API - insights (spend, impressions, clicks, conversions). + +Insights come in two flavours upstream and both are exposed here: + + * **synchronous** - ``GET /{object_id}/insights``. Fine for one account or a + handful of campaigns over a short period. + * **asynchronous** - large reports (long date ranges, many breakdowns, whole + accounts at ad level) are run as a job on Meta's side: start the job, poll + its status, then read the result. Meta will reject or time out a sync call + that is too big, so anything sizeable belongs here. + +``POST /ads/insights/{object_id}/run`` wraps the whole async dance (start → +poll → read) in one call for callers that just want the numbers and can wait. + +``object_id`` is anything Meta can report on: an ad account (``act_123`` or +``123``), a campaign id, an ad set id or an ad id. + +All read-only. Credentials: see ``app.credentials.get_meta_credentials``. +""" +from __future__ import annotations + +import asyncio +import time +from typing import Any + +from fastapi import APIRouter, Depends, Path, Query + +from .. import config +from ..clients.graph import MetaGraphClient +from ..credentials import MetaCredentials, get_meta_credentials +from ..errors import UpstreamError +from ..logging_config import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/ads/insights", tags=["meta-ads: insights"]) + +# Level-agnostic default metrics: valid whether the caller reports at account, +# campaign, adset or ad level. Name/id fields are level-specific (asking for +# ad_id at campaign level is an upstream error), so callers add those via +# `fields` together with `level` - see documentation/meta-ads.md. +_DEFAULT_FIELDS = ( + "spend,impressions,clicks,ctr,cpc,cpm,reach,frequency," + "actions,action_values,date_start,date_stop" +) + +# Terminal states of an async insights job. +_JOB_DONE = "Job Completed" +_JOB_FAILED = {"Job Failed", "Job Skipped"} + + +def _client(creds: MetaCredentials) -> MetaGraphClient: + return MetaGraphClient(creds, service_name="Meta Marketing API") + + +def _object(object_id: str) -> str: + """Normalize the reporting object id. + + A bare numeric ad account id is ambiguous upstream, so a digits-only id that + the caller labelled as an account still needs the act_ prefix; campaign / + adset / ad ids are passed through untouched. + """ + value = object_id.strip() + return value + + +def _insight_params( + fields: str | None, + level: str | None, + date_preset: str | None, + time_range: str | None, + time_increment: str | None, + breakdowns: str | None, + action_breakdowns: str | None, + filtering: str | None, + sort: str | None, + limit: int | None = None, + after: str | None = None, +) -> dict[str, Any]: + return { + "fields": fields, + "level": level, + "date_preset": date_preset, + "time_range": time_range, + "time_increment": time_increment, + "breakdowns": breakdowns, + "action_breakdowns": action_breakdowns, + "filtering": filtering, + "sort": sort, + "limit": limit, + "after": after, + } + + +# Shared Query definitions so the sync and async endpoints stay in step. +_Q_FIELDS = Query(_DEFAULT_FIELDS, description="Comma-separated insight fields/metrics.") +_Q_LEVEL = Query( + None, description="Aggregation level: account, campaign, adset or ad." +) +_Q_DATE_PRESET = Query( + None, + description="Relative period, e.g. today, yesterday, last_7d, last_30d, " + "this_month, last_month, maximum. Ignored if time_range is given.", +) +_Q_TIME_RANGE = Query( + None, + description='Absolute period as JSON: {"since":"2026-06-01","until":"2026-06-30"}.', +) +_Q_TIME_INCREMENT = Query( + None, + description="Row granularity: number of days (e.g. 1 = daily), 'monthly', " + "or 'all_days' for a single summed row.", +) +_Q_BREAKDOWNS = Query( + None, + description="Comma-separated breakdowns, e.g. age,gender or " + "publisher_platform,platform_position or country.", +) +_Q_ACTION_BREAKDOWNS = Query( + None, description="Comma-separated action breakdowns, e.g. action_type." +) +_Q_FILTERING = Query( + None, + description='JSON array of filters, e.g. ' + '[{"field":"spend","operator":"GREATER_THAN","value":100}].', +) +_Q_SORT = Query(None, description="Sort spec, e.g. spend_descending.") + + +@router.get("/{object_id}", summary="Insights, synchronous (small reports)") +async def insights( + object_id: str = Path( + ..., + description="Ad account (act_123), campaign, ad set or ad id to report on.", + ), + fields: str = _Q_FIELDS, + level: str | None = _Q_LEVEL, + date_preset: str | None = _Q_DATE_PRESET, + time_range: str | None = _Q_TIME_RANGE, + time_increment: str | None = _Q_TIME_INCREMENT, + breakdowns: str | None = _Q_BREAKDOWNS, + action_breakdowns: str | None = _Q_ACTION_BREAKDOWNS, + filtering: str | None = _Q_FILTERING, + sort: str | None = _Q_SORT, + limit: int | None = Query(None, ge=1, le=500), + after: str | None = Query(None, description="Paging cursor."), + all_pages: bool = Query( + True, + description="Follow paging.next and return every page (default; capped " + "by META_MAX_PAGES, the result says truncated:true if the cap is hit). " + "Set false for a single raw page. For big reports prefer the async " + "endpoints below - paging a huge sync report is what Meta rejects.", + ), + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + params = _insight_params( + fields, + level, + date_preset, + time_range, + time_increment, + breakdowns, + action_breakdowns, + filtering, + sort, + limit, + after, + ) + path = f"{_object(object_id)}/insights" + client = _client(creds) + if all_pages: + return await client.get_all_pages(path, params) + return await client.get(path, params) + + +@router.post("/{object_id}/jobs", summary="Start an async insights job") +async def start_job( + object_id: str = Path(..., description="Ad account, campaign, ad set or ad id."), + fields: str = _Q_FIELDS, + level: str | None = _Q_LEVEL, + date_preset: str | None = _Q_DATE_PRESET, + time_range: str | None = _Q_TIME_RANGE, + time_increment: str | None = _Q_TIME_INCREMENT, + breakdowns: str | None = _Q_BREAKDOWNS, + action_breakdowns: str | None = _Q_ACTION_BREAKDOWNS, + filtering: str | None = _Q_FILTERING, + sort: str | None = _Q_SORT, + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + """Queue the report on Meta's side. Returns ``report_run_id`` to poll.""" + form = _insight_params( + fields, + level, + date_preset, + time_range, + time_increment, + breakdowns, + action_breakdowns, + filtering, + sort, + ) + return await _client(creds).post(f"{_object(object_id)}/insights", form) + + +@router.get("/jobs/{report_run_id}", summary="Async insights job status") +async def job_status( + report_run_id: str = Path(..., description="report_run_id returned when starting the job."), + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + return await _client(creds).get( + report_run_id.strip(), + { + "fields": "async_status,async_percent_completion,date_start,date_stop," + "time_completed,emails" + }, + ) + + +@router.get("/jobs/{report_run_id}/results", summary="Read a finished async job") +async def job_results( + report_run_id: str = Path(...), + limit: int | None = Query(None, ge=1, le=500), + after: str | None = Query(None), + all_pages: bool = Query(True, description="Follow paging.next across result pages."), + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + path = f"{report_run_id.strip()}/insights" + params = {"limit": limit, "after": after} + client = _client(creds) + if all_pages: + return await client.get_all_pages(path, params) + return await client.get(path, params) + + +@router.post( + "/{object_id}/run", + summary="Async insights: start, wait for completion and return the rows", +) +async def run_and_wait( + object_id: str = Path(..., description="Ad account, campaign, ad set or ad id."), + fields: str = _Q_FIELDS, + level: str | None = _Q_LEVEL, + date_preset: str | None = _Q_DATE_PRESET, + time_range: str | None = _Q_TIME_RANGE, + time_increment: str | None = _Q_TIME_INCREMENT, + breakdowns: str | None = _Q_BREAKDOWNS, + action_breakdowns: str | None = _Q_ACTION_BREAKDOWNS, + filtering: str | None = _Q_FILTERING, + sort: str | None = _Q_SORT, + max_wait_seconds: float | None = Query( + None, + ge=1, + description="Override the wait budget (default META_ASYNC_MAX_WAIT_SECONDS).", + ), + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + """Convenience wrapper over start → poll → read. + + On timeout the job is NOT cancelled - the response carries the + ``report_run_id`` so the caller can keep polling ``/jobs/{id}`` instead of + losing the work already done upstream. + """ + client = _client(creds) + form = _insight_params( + fields, + level, + date_preset, + time_range, + time_increment, + breakdowns, + action_breakdowns, + filtering, + sort, + ) + started = await client.post(f"{_object(object_id)}/insights", form) + run_id = (started or {}).get("report_run_id") if isinstance(started, dict) else None + if not run_id: + raise UpstreamError( + "Meta did not return a report_run_id for the async insights job.", + status=502, + body=started, + ) + + budget = max_wait_seconds or config.META_ASYNC_MAX_WAIT_SECONDS + deadline = time.monotonic() + budget + status_fields = {"fields": "async_status,async_percent_completion"} + + while True: + status = await client.get(str(run_id), status_fields) + async_status = (status or {}).get("async_status") if isinstance(status, dict) else None + + if async_status == _JOB_DONE: + results = await client.get_all_pages(f"{run_id}/insights", None) + if isinstance(results, dict): + results["report_run_id"] = run_id + return results + + if async_status in _JOB_FAILED: + raise UpstreamError( + f"Async insights job {run_id} ended with status '{async_status}'.", + status=502, + body=status, + ) + + if time.monotonic() >= deadline: + # Not an error upstream - the job is still running. Say so clearly + # and hand back the id rather than failing silently or hanging. + logger.warning( + "Async insights job %s still running after %.0fs; returning id to caller.", + run_id, + budget, + ) + return { + "report_run_id": run_id, + "completed": False, + "async_status": async_status, + "async_percent_completion": (status or {}).get("async_percent_completion"), + "detail": ( + f"Job did not finish within {budget:.0f}s. It is still running " + f"upstream - poll /ads/insights/jobs/{run_id} and then read " + f"/ads/insights/jobs/{run_id}/results." + ), + } + + await asyncio.sleep(config.META_ASYNC_POLL_INTERVAL_SECONDS) diff --git a/app/routers/passthrough.py b/app/routers/passthrough.py new file mode 100644 index 0000000..c07c7a2 --- /dev/null +++ b/app/routers/passthrough.py @@ -0,0 +1,79 @@ +"""Generic read-only Graph passthrough. + +The typed endpoints cover the entities and metrics we expect to use daily, but +the Graph API is far larger than that (pages, Instagram accounts, custom +audiences, ad rules, …). Rather than force a deploy every time something new is +needed, this exposes the whole **read** surface behind one endpoint. + +It is GET-only by construction, so it cannot be used to create, modify or delete +anything - the read-only stance of this phase holds even here. Writes will be +explicit, typed endpoints with their own guard rails (see +documentation/overview.md). + +Credentials: see ``app.credentials.get_meta_credentials``. +""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Path, Query, Request + +from ..clients.graph import MetaGraphClient +from ..credentials import MetaCredentials, get_meta_credentials +from ..errors import UpstreamError + +router = APIRouter(prefix="/graph", tags=["meta-ads: generic read"]) + +# Params the proxy owns. A caller must not be able to override the credentials +# we derived from the headers, and all_pages is ours, not Graph's. +_RESERVED_PARAMS = {"access_token", "appsecret_proof", "all_pages"} + + +@router.get( + "/{graph_path:path}", + summary="Any Graph API GET (read-only escape hatch)", +) +async def graph_get( + request: Request, + graph_path: str = Path( + ..., + description="Graph path WITHOUT the version prefix, e.g. " + "'act_123456/customaudiences' or '17841400000000000/media'.", + ), + fields: str | None = Query(None, description="Comma-separated Graph fields."), + limit: int | None = Query(None, ge=1, le=500), + after: str | None = Query(None, description="Paging cursor."), + all_pages: bool = Query( + True, + description="Follow paging.next and return every page (default, capped " + "by META_MAX_PAGES). Set false for a single raw page.", + ), + creds: MetaCredentials = Depends(get_meta_credentials), +) -> Any: + path = graph_path.strip().lstrip("/") + if not path: + raise UpstreamError("A Graph path is required.", status=400) + + # The version comes from config/X-Meta-Api-Version; a version in the path + # would silently override that, so reject it instead of double-prefixing. + first = path.split("/", 1)[0] + if first.startswith("v") and first[1:].replace(".", "").isdigit(): + raise UpstreamError( + f"Do not include the API version ('{first}') in the path - it is " + "taken from X-Meta-Api-Version or the service default.", + status=400, + ) + + # Forward any extra query params verbatim so the full Graph surface stays + # reachable, minus the ones the proxy controls. + params: dict[str, Any] = { + key: value + for key, value in request.query_params.items() + if key not in _RESERVED_PARAMS + } + params.update({"fields": fields, "limit": limit, "after": after}) + + client = MetaGraphClient(creds, service_name="Meta Graph API") + if all_pages: + return await client.get_all_pages(path, params) + return await client.get(path, params) diff --git a/documentation/meta-ads.md b/documentation/meta-ads.md new file mode 100644 index 0000000..a6196cd --- /dev/null +++ b/documentation/meta-ads.md @@ -0,0 +1,201 @@ +# Meta Marketing API + +Proxy over the Meta Graph / Marketing API: reading the ad account structure +(ad accounts, campaigns, ad sets, ads, creatives) and insights (spend, +impressions, clicks, conversions). + +**This phase is read-only.** Every endpoint issues a GET upstream; campaign +management is phase 2 (see `overview.md`, "Deliberately not wired"). + +Base URL: `https://graph.facebook.com/{version}` — the version comes from +`META_API_VERSION` (default `v25.0`) and can be overridden per request with +`X-Meta-Api-Version`, so a Graph upgrade needs no deploy. + +## Credentials + +| Header | Required | Meaning | +| --- | --- | --- | +| `X-Meta-Access-Token` | one of these | Access token → sent upstream as `Authorization: Bearer`. | +| `Authorization` | one of these | Standard `Authorization: Bearer ` — equivalent alternative (the X- header wins if both are sent). | +| `X-Meta-App-Secret` | no, recommended | App secret; the proxy derives `appsecret_proof` per request. | +| `X-Meta-Api-Version` | no | Graph version override, e.g. `v25.0`. | + +> Use a **System User** token from Business Manager. Unlike a user OAuth token +> it does not stop working when someone leaves the company or changes their +> password, and when generated without an expiry it needs no refreshing at all. + +Required permissions for this read-only phase: `ads_read`, +`business_management`. + +## Endpoints + +### Structure + +| Method | Path | Purpose | +| --- | --- | --- | +| GET | `/ads/me/adaccounts` | Ad accounts the token can access. | +| GET | `/ads/me/businesses` | Business Manager accounts the token can access. | +| GET | `/ads/businesses/{business_id}/adaccounts` | Accounts owned by a business (`owned=false` → accounts shared with it). | +| GET | `/ads/accounts/{account_id}` | Ad account detail. | +| GET | `/ads/accounts/{account_id}/campaigns` | Campaigns in an account. | +| GET | `/ads/accounts/{account_id}/adsets` | Ad sets in an account. | +| GET | `/ads/accounts/{account_id}/ads` | Ads in an account. | +| GET | `/ads/accounts/{account_id}/adcreatives` | Creatives in an account. | +| GET | `/ads/campaigns/{campaign_id}` | Campaign detail. | +| GET | `/ads/campaigns/{campaign_id}/adsets` | Ad sets in a campaign. | +| GET | `/ads/campaigns/{campaign_id}/ads` | Ads in a campaign. | +| GET | `/ads/adsets/{adset_id}` | Ad set detail. | +| GET | `/ads/adsets/{adset_id}/ads` | Ads in an ad set. | +| GET | `/ads/ads/{ad_id}` | Ad detail. | +| GET | `/ads/adcreatives/{creative_id}` | Creative detail. | + +`account_id` is accepted with or without the `act_` prefix. + +Every list endpoint takes `fields` (comma-separated Graph fields, with a useful +default), `limit`, `after` (cursor) and `all_pages` (auto-paging, on by +default — see [Pagination](#pagination)). The account-level campaign +/ adset / ad endpoints also take `effective_status`, a JSON array such as +`["ACTIVE","PAUSED"]`. + +### Insights + +| Method | Path | Purpose | +| --- | --- | --- | +| GET | `/ads/insights/{object_id}` | Synchronous insights — small reports. | +| POST | `/ads/insights/{object_id}/jobs` | Start an async report job → `report_run_id`. | +| GET | `/ads/insights/jobs/{report_run_id}` | Job status (`async_status`, `async_percent_completion`). | +| GET | `/ads/insights/jobs/{report_run_id}/results` | Read a finished job. | +| POST | `/ads/insights/{object_id}/run` | Start, wait for completion, return the rows. | + +`object_id` is an ad account (`act_123`), campaign, ad set or ad id. + +Parameters (identical across all of them): `fields`, `level`, `date_preset`, +`time_range`, `time_increment`, `breakdowns`, `action_breakdowns`, `filtering`, +`sort`. + +**Sync or async?** Meta computes large reports asynchronously and will reject or +time out a sync call that is too big. Rule of thumb: one account or a handful of +campaigns over a short period → `GET`. Long periods, several breakdowns, or a +whole account at ad level → `/run`. + +`/run` polls until the job finishes or the wait budget +(`META_ASYNC_MAX_WAIT_SECONDS`, default 120 s, overridable per call with +`max_wait_seconds`) runs out. On timeout the job is **not** cancelled — the +response carries `completed: false` and the `report_run_id`, so you can keep +polling `/ads/insights/jobs/{report_run_id}` instead of losing the work. + +#### Fields and levels + +The default `fields` list is deliberately level-agnostic: + +``` +spend,impressions,clicks,ctr,cpc,cpm,reach,frequency,actions,action_values,date_start,date_stop +``` + +Name/id fields are level-specific — asking for `ad_id` at campaign level is an +upstream error. Add them together with `level`: + +- `level=campaign` → `campaign_id,campaign_name,…` +- `level=adset` → `adset_id,adset_name,campaign_name,…` +- `level=ad` → `ad_id,ad_name,adset_name,campaign_name,…` + +Conversions live in `actions` / `action_values` (arrays keyed by +`action_type`), not in a top-level metric. + +### Generic read passthrough + +| Method | Path | Purpose | +| --- | --- | --- | +| GET | `/graph/{graph_path}` | Any Graph API GET. | + +For everything the typed endpoints do not cover (custom audiences, ad rules, +Instagram accounts, Pages…) without needing a deploy. GET-only by construction, +so the read-only stance holds here too. Give the path **without** the version +prefix — the version comes from config or `X-Meta-Api-Version`. Unknown query +params are forwarded verbatim. + +## Pagination + +The proxy **follows `paging.next` for you by default** (`all_pages=true`), so a +list call returns the complete list: + +```json +{ "data": [ ... ], "pages_read": 3, "truncated": false } +``` + +`truncated: true` means the `META_MAX_PAGES` cap (default 100) was hit; the +response also carries the `next` URL and a warning is logged. Graph's own page +size defaults to 25 — pass a bigger `limit` (up to 500 on most edges) to fetch +more rows in fewer round trips. + +Pass `all_pages=false` to get a single raw page with the upstream +`paging.cursors` untouched and drive the cursor yourself. + +## Rate limits + +`X-App-Usage`, `X-Ad-Account-Usage` and `X-Business-Use-Case-Usage` from the +upstream response are copied onto our response — use them to pace calls. + +## Errors + +| Status | Meaning | +| --- | --- | +| 401 `missing_credentials` | No token, or a malformed `X-Meta-Api-Version`. | +| 400 `upstream_error` | Meta rejected the request (bad field, bad id, missing permission). | +| 502 / 504 | Meta unreachable, 5xx, or a request timeout. | + +Upstream errors keep Meta's status and the whole error object in +`upstream_body`, including `code`, `error_subcode` and `fbtrace_id` (quote it +when opening a case with Meta). `detail` prefers Meta's human-readable +`error_user_msg` when present. + +Common ones: + +- **code 100, "Invalid appsecret_proof"** — the app enforces app-secret proof; + send `X-Meta-App-Secret`. +- **code 190** — token expired or revoked (a user OAuth token after a password + change; a System User token generated with an expiry). +- **code 200** — the token lacks `ads_read` on that ad account. +- **code 17 / 613** — rate limited; check the usage headers and back off. + +## Kde získat údaje (návod pro klienta) + +- **Access token (System User)**: [Business Manager](https://business.facebook.com/) + → *Nastavení firmy → Uživatelé → Systémoví uživatelé* → **Přidat** → + **Přidat aktiva** (vyberte reklamní účty, oprávnění *Správa kampaní*) → + **Vygenerovat nový token**. Bez zadané expirace je token trvalý. + Oprávnění pro tuto fázi: `ads_read`, `business_management`. +- **App secret**: [developers.facebook.com](https://developers.facebook.com/apps/) + → vaše aplikace → *Nastavení → Základní → App Secret*. Povinný, pokud má + aplikace zapnuté *Require app secret proof for server API calls*. +- **ID reklamního účtu**: Business Manager → *Nastavení firmy → Reklamní účty*, + nebo vlevo nahoře v Ads Manageru. Prefix `act_` je volitelný. + +## curl examples + +Campaigns in an account: + +```bash +curl "https://services.csbot.cz/apps/meta/ads/accounts/123456789/campaigns?limit=200" \ + -H "X-Meta-Access-Token: EAAG..." \ + -H "X-Meta-App-Secret: " +``` + +Daily campaign insights for June, run asynchronously: + +```bash +curl -X POST "https://services.csbot.cz/apps/meta/ads/insights/act_123456789/run\ +?level=campaign\ +&fields=campaign_id,campaign_name,spend,impressions,clicks,ctr,actions\ +&time_range=%7B%22since%22%3A%222026-06-01%22%2C%22until%22%3A%222026-06-30%22%7D\ +&time_increment=1" \ + -H "X-Meta-Access-Token: EAAG..." \ + -H "X-Meta-App-Secret: " +``` + +Anything not covered by a typed endpoint: + +```bash +curl "https://services.csbot.cz/apps/meta/graph/act_123456789/customaudiences?fields=id,name,approximate_count" \ + -H "X-Meta-Access-Token: EAAG..." +``` diff --git a/documentation/overview.md b/documentation/overview.md new file mode 100644 index 0000000..d630a87 --- /dev/null +++ b/documentation/overview.md @@ -0,0 +1,132 @@ +# meta — overview + +A stateless multi-tenant API proxy over the **Meta Marketing API** +(Facebook / Instagram advertising), exposed as one FastAPI app at +`/apps/meta`. + +The structure deliberately mirrors the sibling `analytics` service +(config→env, credentials→headers, client per upstream, routers, central +exception handling, Swagger at `/docs`), so anyone who knows one knows the +other. + +## Why a separate app and not a module in `analytics` + +- `analytics` is built around one shared Google OAuth mechanism (token minting + from service-account keys, scopes, quota project). Meta shares none of it. +- The Graph API needs a different client: form-encoded writes, `appsecret_proof`, + cursor pagination, async report jobs. +- This module serves both **Agents** and **csbot**, so it is versioned and + deployed independently. +- `analytics` is declared read-only. Campaign management (phase 2) lands here, + where the write guard rails can be designed from the start. + +## Design principles + +- **Stateless / no stored secrets.** Credentials arrive per request in `X-` + headers and are used only to call the upstream. Nothing is persisted; there + is no token cache, because the caller always supplies a fresh token. +- **Thin passthrough.** Graph request/response bodies are forwarded as-is, so + callers keep the full upstream API surface. Only authentication, versioned URL + building, pagination and error mapping are added. +- **No silent failures.** Every error is logged (never the secret values) and + surfaced as JSON. Upstream errors preserve the upstream status and body. +- **Version is config, not code.** `META_API_VERSION` sets the default and + `X-Meta-Api-Version` overrides it per request, so a Graph upgrade never needs + a deploy here. + +## Layout + +``` +app/ + config.py env-driven config (base URL, version, caps, timeout) — no secrets + logging_config.py get_logger(); secrets are never logged + errors.py MissingCredentialsError, UpstreamError + handlers + credentials.py X- header dependencies + appsecret_proof derivation + clients/ + graph.py Graph client: auth, URL building, paging, error mapping, + rate-limit header capture + routers/ + infra.py /health, /version + entities.py /ads/... ad accounts, campaigns, ad sets, ads, creatives + insights.py /ads/insights/... sync + async reporting + passthrough.py /graph/{path} generic read-only Graph GET + main.py app, root_path, usage middleware, router + handler registration +``` + +## Reverse proxy + +`ROOT_PATH` (`/apps/meta`) is passed to FastAPI's `root_path`, so the OpenAPI +`servers` entry and Swagger "Try it out" use the public prefix. Internal routes +are unprefixed (Caddy `handle_path` strips the prefix). + +## Authentication summary + +| Header | Required | Behaviour | +| --- | --- | --- | +| `X-Meta-Access-Token` | one of these | Sent upstream as `Authorization: Bearer`. A Business Manager **System User** token is recommended. | +| `Authorization: Bearer ` | one of these | Equivalent alternative; `X-Meta-Access-Token` wins if both are present. | +| `X-Meta-App-Secret` | no, recommended | The proxy derives `appsecret_proof` (HMAC-SHA256 of the token) per request. Required when the app enforces app-secret proof. | +| `X-Meta-Api-Version` | no | Per-request Graph version, e.g. `v25.0`. Validated as `vNN.N`, optionally restricted by `META_ALLOWED_API_VERSIONS`. | + +The token goes upstream as a **header**, never as an `access_token` query +param, so tokens do not end up in intermediate access logs. + +## Configuration (environment variables) + +| Variable | Default | Meaning | +| --- | --- | --- | +| `META_GRAPH_BASE_URL` | `https://graph.facebook.com` | Graph base URL. | +| `META_API_VERSION` | `v25.0` | Default Graph version. | +| `META_ALLOWED_API_VERSIONS` | *(empty)* | Optional comma-separated allowlist for `X-Meta-Api-Version`. Empty = any well-formed version. | +| `META_MAX_PAGES` | `100` | Page cap for auto-paging. | +| `META_ASYNC_POLL_INTERVAL_SECONDS` | `2` | Poll interval for async insights jobs. | +| `META_ASYNC_MAX_WAIT_SECONDS` | `120` | Wait budget for `/run`. | +| `HTTP_TIMEOUT_SECONDS` | `60` | Upstream request timeout. | +| `LOG_LEVEL` | `INFO` | Log level. | + +## Rate limits + +Meta reports quota consumption in `X-App-Usage`, `X-Ad-Account-Usage` and +`X-Business-Use-Case-Usage`. A middleware copies whatever the upstream returned +onto our own response, so callers can pace themselves instead of discovering a +throttle by being blocked. + +## Pagination + +List endpoints **follow `paging.next` by default** (`all_pages=true`) and return +the complete list, so callers never have to implement a cursor loop: + +```json +{ "data": [...], "pages_read": 3, "truncated": false } +``` + +`truncated: true` (plus a `next` URL and a logged warning) means the +`META_MAX_PAGES` cap was hit — a partial result never masquerades as a complete +one. Graph's own page size defaults to 25, so pass a larger `limit` to cover +more rows in fewer round trips. + +Pass `all_pages=false` for a single raw page with the upstream `paging.cursors` +untouched, when you want to drive the cursor yourself. + +## Deliberately not wired + +- **All write operations (phase 2).** Creating or updating campaigns, ad sets + and ads is intentionally absent. Every current endpoint issues a GET, so the + service cannot spend money. Before writes are added, the agreed pattern is: + status forced to `PAUSED` server-side (activation stays manual), an + idempotency key so a retried request cannot create a duplicate campaign, and a + budget ceiling enforced here as a backstop. +- **Instagram organic / Pages content.** Reachable today through the read-only + `/graph/{path}` passthrough; typed endpoints can follow if they get regular use. +- **Header-credential encryption.** Same deferral as `analytics` / `idoklad` / + `csob`: header values are plaintext over TLS for now. +- **Automated tests.** There is no test suite yet (matching `analytics`). + Verification is the manual checklist below. This is acceptable while the + service is read-only; **it should not stay that way once writes land.** + +## Verification checklist (per AGENTS.md) + +- `/health` returns 200. +- `/docs` loads; `/openapi.json` `servers` contains `/apps/meta`. +- New endpoints appear in Swagger with their `X-` headers in "Try it out". +- Secrets never appear in logs or source. diff --git a/requirements.txt b/requirements.txt index 364e2ee..72cd832 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ fastapi uvicorn[standard] +httpx