This commit is contained in:
JiriUhlir
2026-07-20 07:36:55 +02:00
parent 496104f65a
commit a7cb53e0e6
16 changed files with 1856 additions and 19 deletions
+67 -1
View File
@@ -1,3 +1,69 @@
# Meta services # 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: <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 <token>` | 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.
View File
+231
View File
@@ -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,
)
+57
View File
@@ -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"))
+143
View File
@@ -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 <token>`` 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 <token>`` 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 "
"<token>'. 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 <token>' 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),
)
+85
View File
@@ -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))
+30
View File
@@ -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)
+140 -18
View File
@@ -1,25 +1,147 @@
import os """meta - stateless API proxy for the Meta Marketing API (Facebook/Instagram).
from fastapi import FastAPI
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/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", "") 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 <token>` | 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( app = FastAPI(
title=APP_NAME, title=config.APP_NAME,
version=APP_VERSION, version=config.APP_VERSION,
root_path=ROOT_PATH description=DESCRIPTION,
root_path=ROOT_PATH,
) )
@app.get("/health") register_exception_handlers(app)
def health():
return {"status": "ok"}
@app.get("/version")
def version(): @app.middleware("http")
return { async def _propagate_usage_headers(request: Request, call_next):
"app": APP_NAME, """Echo Meta's rate-limit headers back to the caller.
"version": APP_VERSION,
"language": "python", An empty dict is installed here and mutated by the Graph client (see
"root_path": ROOT_PATH ``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,
)
View File
+341
View File
@@ -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)
+24
View File
@@ -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,
}
+325
View File
@@ -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)
+79
View File
@@ -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)
+201
View File
@@ -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 <token>` — 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: <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: <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..."
```
+132
View File
@@ -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 <token>` | 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.
+1
View File
@@ -1,2 +1,3 @@
fastapi fastapi
uvicorn[standard] uvicorn[standard]
httpx