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
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
from fastapi import FastAPI
"""meta - stateless API proxy for the Meta Marketing API (Facebook/Instagram).
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", "")
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(
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,
)
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)