80 lines
3.1 KiB
Python
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)
|