first
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"))
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
@@ -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
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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..."
|
||||||
|
```
|
||||||
@@ -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,2 +1,3 @@
|
|||||||
fastapi
|
fastapi
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
|
httpx
|
||||||
|
|||||||
Reference in New Issue
Block a user