Files
meta/app/routers/passthrough.py
T
JiriUhlir a7cb53e0e6 first
2026-07-20 07:36:55 +02:00

80 lines
3.1 KiB
Python

"""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)