This commit is contained in:
JiriUhlir
2026-07-20 07:36:55 +02:00
parent 496104f65a
commit a7cb53e0e6
16 changed files with 1856 additions and 19 deletions
View File
+341
View File
@@ -0,0 +1,341 @@
"""Meta Marketing API - reading the ad account structure.
Ad accounts, campaigns, ad sets, ads and creatives. All read-only: this module
issues GETs only. Creating or updating entities is deliberately not wired yet
(see documentation/overview.md, "Deliberately not wired").
Every list endpoint takes the same three knobs:
* ``fields`` - Graph field list; each endpoint has a useful default so a
caller who does not know the Graph schema still gets meaningful data;
* ``limit`` / ``after`` - page size and cursor;
* ``all_pages`` - **on by default**: the proxy follows ``paging.next`` so the
caller gets the complete list without looping. Capped by ``META_MAX_PAGES``
and flagged ``truncated`` if the cap is hit. Set it to false for a single
raw page with the upstream cursors.
Credentials: see ``app.credentials.get_meta_credentials``.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Path, Query
from ..clients.graph import MetaGraphClient
from ..credentials import MetaCredentials, get_meta_credentials
router = APIRouter(prefix="/ads", tags=["meta-ads: entities"])
# Field defaults. Kept modest on purpose - asking Graph for every field on a
# large account is slow and often trips per-field permission errors.
_ACCOUNT_FIELDS = (
"id,account_id,name,account_status,currency,timezone_name,business,"
"amount_spent,balance,spend_cap"
)
_CAMPAIGN_FIELDS = (
"id,name,status,effective_status,objective,buying_type,daily_budget,"
"lifetime_budget,budget_remaining,start_time,stop_time,created_time,updated_time"
)
_ADSET_FIELDS = (
"id,name,status,effective_status,campaign_id,daily_budget,lifetime_budget,"
"billing_event,optimization_goal,bid_amount,targeting,start_time,end_time,"
"created_time,updated_time"
)
_AD_FIELDS = (
"id,name,status,effective_status,adset_id,campaign_id,creative,"
"created_time,updated_time"
)
_CREATIVE_FIELDS = (
"id,name,status,object_story_spec,asset_feed_spec,thumbnail_url,image_url,"
"body,title,call_to_action_type,effective_object_story_id"
)
# Shared so every list endpoint documents pagination identically.
_Q_ALL_PAGES = Query(
True,
description="Follow paging.next and return every page (default). The result "
"carries pages_read and truncated; truncated:true means the META_MAX_PAGES "
"cap was hit. Set false to get a single raw page with upstream cursors.",
)
def _client(creds: MetaCredentials) -> MetaGraphClient:
return MetaGraphClient(creds, service_name="Meta Marketing API")
def _account(account_id: str) -> str:
"""Normalize an ad account id: both '123' and 'act_123' are accepted."""
value = account_id.strip()
return value if value.startswith("act_") else f"act_{value}"
async def _read(
creds: MetaCredentials,
path: str,
*,
fields: str | None,
limit: int | None = None,
after: str | None = None,
all_pages: bool = False,
extra: dict[str, Any] | None = None,
) -> Any:
params: dict[str, Any] = {"fields": fields, "limit": limit, "after": after}
if extra:
params.update(extra)
client = _client(creds)
if all_pages:
return await client.get_all_pages(path, params)
return await client.get(path, params)
# --- Ad accounts --------------------------------------------------------------
@router.get("/me/adaccounts", summary="Ad accounts the token can access")
async def my_ad_accounts(
fields: str = Query(_ACCOUNT_FIELDS, description="Comma-separated Graph fields."),
limit: int | None = Query(None, ge=1, le=500),
after: str | None = Query(None, description="Paging cursor from paging.cursors.after."),
all_pages: bool = _Q_ALL_PAGES,
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(
creds, "me/adaccounts", fields=fields, limit=limit, after=after, all_pages=all_pages
)
@router.get("/me/businesses", summary="Business Manager accounts the token can access")
async def my_businesses(
fields: str = Query("id,name,created_time", description="Comma-separated Graph fields."),
limit: int | None = Query(None, ge=1, le=500),
after: str | None = Query(None),
all_pages: bool = _Q_ALL_PAGES,
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(
creds, "me/businesses", fields=fields, limit=limit, after=after, all_pages=all_pages
)
@router.get(
"/businesses/{business_id}/adaccounts",
summary="Ad accounts owned by (or shared with) a business",
)
async def business_ad_accounts(
business_id: str = Path(..., description="Business Manager id."),
owned: bool = Query(
True,
description="True = owned_ad_accounts (accounts the business owns); "
"False = client_ad_accounts (accounts shared with it, agency case).",
),
fields: str = Query(_ACCOUNT_FIELDS),
limit: int | None = Query(None, ge=1, le=500),
after: str | None = Query(None),
all_pages: bool = _Q_ALL_PAGES,
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
edge = "owned_ad_accounts" if owned else "client_ad_accounts"
return await _read(
creds,
f"{business_id.strip()}/{edge}",
fields=fields,
limit=limit,
after=after,
all_pages=all_pages,
)
@router.get("/accounts/{account_id}", summary="Ad account detail")
async def ad_account(
account_id: str = Path(..., description="Ad account id, with or without the act_ prefix."),
fields: str = Query(_ACCOUNT_FIELDS),
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(creds, _account(account_id), fields=fields)
# --- Campaigns ----------------------------------------------------------------
@router.get("/accounts/{account_id}/campaigns", summary="Campaigns in an ad account")
async def account_campaigns(
account_id: str = Path(..., description="Ad account id, with or without act_."),
fields: str = Query(_CAMPAIGN_FIELDS),
effective_status: str | None = Query(
None,
description='Optional JSON array of statuses to keep, e.g. ["ACTIVE","PAUSED"].',
),
limit: int | None = Query(None, ge=1, le=500),
after: str | None = Query(None),
all_pages: bool = _Q_ALL_PAGES,
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(
creds,
f"{_account(account_id)}/campaigns",
fields=fields,
limit=limit,
after=after,
all_pages=all_pages,
extra={"effective_status": effective_status},
)
@router.get("/campaigns/{campaign_id}", summary="Campaign detail")
async def campaign(
campaign_id: str = Path(...),
fields: str = Query(_CAMPAIGN_FIELDS),
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(creds, campaign_id.strip(), fields=fields)
@router.get("/campaigns/{campaign_id}/adsets", summary="Ad sets in a campaign")
async def campaign_adsets(
campaign_id: str = Path(...),
fields: str = Query(_ADSET_FIELDS),
limit: int | None = Query(None, ge=1, le=500),
after: str | None = Query(None),
all_pages: bool = _Q_ALL_PAGES,
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(
creds,
f"{campaign_id.strip()}/adsets",
fields=fields,
limit=limit,
after=after,
all_pages=all_pages,
)
@router.get("/campaigns/{campaign_id}/ads", summary="Ads in a campaign")
async def campaign_ads(
campaign_id: str = Path(...),
fields: str = Query(_AD_FIELDS),
limit: int | None = Query(None, ge=1, le=500),
after: str | None = Query(None),
all_pages: bool = _Q_ALL_PAGES,
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(
creds,
f"{campaign_id.strip()}/ads",
fields=fields,
limit=limit,
after=after,
all_pages=all_pages,
)
# --- Ad sets ------------------------------------------------------------------
@router.get("/accounts/{account_id}/adsets", summary="Ad sets in an ad account")
async def account_adsets(
account_id: str = Path(...),
fields: str = Query(_ADSET_FIELDS),
effective_status: str | None = Query(
None, description='Optional JSON array, e.g. ["ACTIVE"].'
),
limit: int | None = Query(None, ge=1, le=500),
after: str | None = Query(None),
all_pages: bool = _Q_ALL_PAGES,
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(
creds,
f"{_account(account_id)}/adsets",
fields=fields,
limit=limit,
after=after,
all_pages=all_pages,
extra={"effective_status": effective_status},
)
@router.get("/adsets/{adset_id}", summary="Ad set detail")
async def adset(
adset_id: str = Path(...),
fields: str = Query(_ADSET_FIELDS),
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(creds, adset_id.strip(), fields=fields)
@router.get("/adsets/{adset_id}/ads", summary="Ads in an ad set")
async def adset_ads(
adset_id: str = Path(...),
fields: str = Query(_AD_FIELDS),
limit: int | None = Query(None, ge=1, le=500),
after: str | None = Query(None),
all_pages: bool = _Q_ALL_PAGES,
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(
creds,
f"{adset_id.strip()}/ads",
fields=fields,
limit=limit,
after=after,
all_pages=all_pages,
)
# --- Ads and creatives --------------------------------------------------------
@router.get("/accounts/{account_id}/ads", summary="Ads in an ad account")
async def account_ads(
account_id: str = Path(...),
fields: str = Query(_AD_FIELDS),
effective_status: str | None = Query(
None, description='Optional JSON array, e.g. ["ACTIVE"].'
),
limit: int | None = Query(None, ge=1, le=500),
after: str | None = Query(None),
all_pages: bool = _Q_ALL_PAGES,
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(
creds,
f"{_account(account_id)}/ads",
fields=fields,
limit=limit,
after=after,
all_pages=all_pages,
extra={"effective_status": effective_status},
)
@router.get("/ads/{ad_id}", summary="Ad detail")
async def ad(
ad_id: str = Path(...),
fields: str = Query(_AD_FIELDS),
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(creds, ad_id.strip(), fields=fields)
@router.get(
"/accounts/{account_id}/adcreatives", summary="Ad creatives in an ad account"
)
async def account_creatives(
account_id: str = Path(...),
fields: str = Query(_CREATIVE_FIELDS),
limit: int | None = Query(None, ge=1, le=500),
after: str | None = Query(None),
all_pages: bool = _Q_ALL_PAGES,
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(
creds,
f"{_account(account_id)}/adcreatives",
fields=fields,
limit=limit,
after=after,
all_pages=all_pages,
)
@router.get("/adcreatives/{creative_id}", summary="Ad creative detail")
async def creative(
creative_id: str = Path(...),
fields: str = Query(_CREATIVE_FIELDS),
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _read(creds, creative_id.strip(), fields=fields)
+24
View File
@@ -0,0 +1,24 @@
"""Infrastructure endpoints required by AppFactory. No credentials needed."""
from fastapi import APIRouter
from .. import config
router = APIRouter(tags=["infra"])
@router.get("/health", summary="Liveness/readiness probe")
def health() -> dict:
"""Return 200 while the app can serve traffic. Used by AppFactory monitoring."""
return {"status": "ok"}
@router.get("/version", summary="Service version and build info")
def version() -> dict:
return {
"app": config.APP_NAME,
"version": config.APP_VERSION,
"language": "python",
"root_path": config.ROOT_PATH,
"integrations": ["meta-marketing-api"],
"graph_api_version": config.META_API_VERSION,
}
+325
View File
@@ -0,0 +1,325 @@
"""Meta Marketing API - insights (spend, impressions, clicks, conversions).
Insights come in two flavours upstream and both are exposed here:
* **synchronous** - ``GET /{object_id}/insights``. Fine for one account or a
handful of campaigns over a short period.
* **asynchronous** - large reports (long date ranges, many breakdowns, whole
accounts at ad level) are run as a job on Meta's side: start the job, poll
its status, then read the result. Meta will reject or time out a sync call
that is too big, so anything sizeable belongs here.
``POST /ads/insights/{object_id}/run`` wraps the whole async dance (start →
poll → read) in one call for callers that just want the numbers and can wait.
``object_id`` is anything Meta can report on: an ad account (``act_123`` or
``123``), a campaign id, an ad set id or an ad id.
All read-only. Credentials: see ``app.credentials.get_meta_credentials``.
"""
from __future__ import annotations
import asyncio
import time
from typing import Any
from fastapi import APIRouter, Depends, Path, Query
from .. import config
from ..clients.graph import MetaGraphClient
from ..credentials import MetaCredentials, get_meta_credentials
from ..errors import UpstreamError
from ..logging_config import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/ads/insights", tags=["meta-ads: insights"])
# Level-agnostic default metrics: valid whether the caller reports at account,
# campaign, adset or ad level. Name/id fields are level-specific (asking for
# ad_id at campaign level is an upstream error), so callers add those via
# `fields` together with `level` - see documentation/meta-ads.md.
_DEFAULT_FIELDS = (
"spend,impressions,clicks,ctr,cpc,cpm,reach,frequency,"
"actions,action_values,date_start,date_stop"
)
# Terminal states of an async insights job.
_JOB_DONE = "Job Completed"
_JOB_FAILED = {"Job Failed", "Job Skipped"}
def _client(creds: MetaCredentials) -> MetaGraphClient:
return MetaGraphClient(creds, service_name="Meta Marketing API")
def _object(object_id: str) -> str:
"""Normalize the reporting object id.
A bare numeric ad account id is ambiguous upstream, so a digits-only id that
the caller labelled as an account still needs the act_ prefix; campaign /
adset / ad ids are passed through untouched.
"""
value = object_id.strip()
return value
def _insight_params(
fields: str | None,
level: str | None,
date_preset: str | None,
time_range: str | None,
time_increment: str | None,
breakdowns: str | None,
action_breakdowns: str | None,
filtering: str | None,
sort: str | None,
limit: int | None = None,
after: str | None = None,
) -> dict[str, Any]:
return {
"fields": fields,
"level": level,
"date_preset": date_preset,
"time_range": time_range,
"time_increment": time_increment,
"breakdowns": breakdowns,
"action_breakdowns": action_breakdowns,
"filtering": filtering,
"sort": sort,
"limit": limit,
"after": after,
}
# Shared Query definitions so the sync and async endpoints stay in step.
_Q_FIELDS = Query(_DEFAULT_FIELDS, description="Comma-separated insight fields/metrics.")
_Q_LEVEL = Query(
None, description="Aggregation level: account, campaign, adset or ad."
)
_Q_DATE_PRESET = Query(
None,
description="Relative period, e.g. today, yesterday, last_7d, last_30d, "
"this_month, last_month, maximum. Ignored if time_range is given.",
)
_Q_TIME_RANGE = Query(
None,
description='Absolute period as JSON: {"since":"2026-06-01","until":"2026-06-30"}.',
)
_Q_TIME_INCREMENT = Query(
None,
description="Row granularity: number of days (e.g. 1 = daily), 'monthly', "
"or 'all_days' for a single summed row.",
)
_Q_BREAKDOWNS = Query(
None,
description="Comma-separated breakdowns, e.g. age,gender or "
"publisher_platform,platform_position or country.",
)
_Q_ACTION_BREAKDOWNS = Query(
None, description="Comma-separated action breakdowns, e.g. action_type."
)
_Q_FILTERING = Query(
None,
description='JSON array of filters, e.g. '
'[{"field":"spend","operator":"GREATER_THAN","value":100}].',
)
_Q_SORT = Query(None, description="Sort spec, e.g. spend_descending.")
@router.get("/{object_id}", summary="Insights, synchronous (small reports)")
async def insights(
object_id: str = Path(
...,
description="Ad account (act_123), campaign, ad set or ad id to report on.",
),
fields: str = _Q_FIELDS,
level: str | None = _Q_LEVEL,
date_preset: str | None = _Q_DATE_PRESET,
time_range: str | None = _Q_TIME_RANGE,
time_increment: str | None = _Q_TIME_INCREMENT,
breakdowns: str | None = _Q_BREAKDOWNS,
action_breakdowns: str | None = _Q_ACTION_BREAKDOWNS,
filtering: str | None = _Q_FILTERING,
sort: str | None = _Q_SORT,
limit: int | None = Query(None, ge=1, le=500),
after: str | None = Query(None, description="Paging cursor."),
all_pages: bool = Query(
True,
description="Follow paging.next and return every page (default; capped "
"by META_MAX_PAGES, the result says truncated:true if the cap is hit). "
"Set false for a single raw page. For big reports prefer the async "
"endpoints below - paging a huge sync report is what Meta rejects.",
),
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
params = _insight_params(
fields,
level,
date_preset,
time_range,
time_increment,
breakdowns,
action_breakdowns,
filtering,
sort,
limit,
after,
)
path = f"{_object(object_id)}/insights"
client = _client(creds)
if all_pages:
return await client.get_all_pages(path, params)
return await client.get(path, params)
@router.post("/{object_id}/jobs", summary="Start an async insights job")
async def start_job(
object_id: str = Path(..., description="Ad account, campaign, ad set or ad id."),
fields: str = _Q_FIELDS,
level: str | None = _Q_LEVEL,
date_preset: str | None = _Q_DATE_PRESET,
time_range: str | None = _Q_TIME_RANGE,
time_increment: str | None = _Q_TIME_INCREMENT,
breakdowns: str | None = _Q_BREAKDOWNS,
action_breakdowns: str | None = _Q_ACTION_BREAKDOWNS,
filtering: str | None = _Q_FILTERING,
sort: str | None = _Q_SORT,
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
"""Queue the report on Meta's side. Returns ``report_run_id`` to poll."""
form = _insight_params(
fields,
level,
date_preset,
time_range,
time_increment,
breakdowns,
action_breakdowns,
filtering,
sort,
)
return await _client(creds).post(f"{_object(object_id)}/insights", form)
@router.get("/jobs/{report_run_id}", summary="Async insights job status")
async def job_status(
report_run_id: str = Path(..., description="report_run_id returned when starting the job."),
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
return await _client(creds).get(
report_run_id.strip(),
{
"fields": "async_status,async_percent_completion,date_start,date_stop,"
"time_completed,emails"
},
)
@router.get("/jobs/{report_run_id}/results", summary="Read a finished async job")
async def job_results(
report_run_id: str = Path(...),
limit: int | None = Query(None, ge=1, le=500),
after: str | None = Query(None),
all_pages: bool = Query(True, description="Follow paging.next across result pages."),
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
path = f"{report_run_id.strip()}/insights"
params = {"limit": limit, "after": after}
client = _client(creds)
if all_pages:
return await client.get_all_pages(path, params)
return await client.get(path, params)
@router.post(
"/{object_id}/run",
summary="Async insights: start, wait for completion and return the rows",
)
async def run_and_wait(
object_id: str = Path(..., description="Ad account, campaign, ad set or ad id."),
fields: str = _Q_FIELDS,
level: str | None = _Q_LEVEL,
date_preset: str | None = _Q_DATE_PRESET,
time_range: str | None = _Q_TIME_RANGE,
time_increment: str | None = _Q_TIME_INCREMENT,
breakdowns: str | None = _Q_BREAKDOWNS,
action_breakdowns: str | None = _Q_ACTION_BREAKDOWNS,
filtering: str | None = _Q_FILTERING,
sort: str | None = _Q_SORT,
max_wait_seconds: float | None = Query(
None,
ge=1,
description="Override the wait budget (default META_ASYNC_MAX_WAIT_SECONDS).",
),
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
"""Convenience wrapper over start → poll → read.
On timeout the job is NOT cancelled - the response carries the
``report_run_id`` so the caller can keep polling ``/jobs/{id}`` instead of
losing the work already done upstream.
"""
client = _client(creds)
form = _insight_params(
fields,
level,
date_preset,
time_range,
time_increment,
breakdowns,
action_breakdowns,
filtering,
sort,
)
started = await client.post(f"{_object(object_id)}/insights", form)
run_id = (started or {}).get("report_run_id") if isinstance(started, dict) else None
if not run_id:
raise UpstreamError(
"Meta did not return a report_run_id for the async insights job.",
status=502,
body=started,
)
budget = max_wait_seconds or config.META_ASYNC_MAX_WAIT_SECONDS
deadline = time.monotonic() + budget
status_fields = {"fields": "async_status,async_percent_completion"}
while True:
status = await client.get(str(run_id), status_fields)
async_status = (status or {}).get("async_status") if isinstance(status, dict) else None
if async_status == _JOB_DONE:
results = await client.get_all_pages(f"{run_id}/insights", None)
if isinstance(results, dict):
results["report_run_id"] = run_id
return results
if async_status in _JOB_FAILED:
raise UpstreamError(
f"Async insights job {run_id} ended with status '{async_status}'.",
status=502,
body=status,
)
if time.monotonic() >= deadline:
# Not an error upstream - the job is still running. Say so clearly
# and hand back the id rather than failing silently or hanging.
logger.warning(
"Async insights job %s still running after %.0fs; returning id to caller.",
run_id,
budget,
)
return {
"report_run_id": run_id,
"completed": False,
"async_status": async_status,
"async_percent_completion": (status or {}).get("async_percent_completion"),
"detail": (
f"Job did not finish within {budget:.0f}s. It is still running "
f"upstream - poll /ads/insights/jobs/{run_id} and then read "
f"/ads/insights/jobs/{run_id}/results."
),
}
await asyncio.sleep(config.META_ASYNC_POLL_INTERVAL_SECONDS)
+79
View File
@@ -0,0 +1,79 @@
"""Generic read-only Graph passthrough.
The typed endpoints cover the entities and metrics we expect to use daily, but
the Graph API is far larger than that (pages, Instagram accounts, custom
audiences, ad rules, …). Rather than force a deploy every time something new is
needed, this exposes the whole **read** surface behind one endpoint.
It is GET-only by construction, so it cannot be used to create, modify or delete
anything - the read-only stance of this phase holds even here. Writes will be
explicit, typed endpoints with their own guard rails (see
documentation/overview.md).
Credentials: see ``app.credentials.get_meta_credentials``.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Path, Query, Request
from ..clients.graph import MetaGraphClient
from ..credentials import MetaCredentials, get_meta_credentials
from ..errors import UpstreamError
router = APIRouter(prefix="/graph", tags=["meta-ads: generic read"])
# Params the proxy owns. A caller must not be able to override the credentials
# we derived from the headers, and all_pages is ours, not Graph's.
_RESERVED_PARAMS = {"access_token", "appsecret_proof", "all_pages"}
@router.get(
"/{graph_path:path}",
summary="Any Graph API GET (read-only escape hatch)",
)
async def graph_get(
request: Request,
graph_path: str = Path(
...,
description="Graph path WITHOUT the version prefix, e.g. "
"'act_123456/customaudiences' or '17841400000000000/media'.",
),
fields: str | None = Query(None, description="Comma-separated Graph fields."),
limit: int | None = Query(None, ge=1, le=500),
after: str | None = Query(None, description="Paging cursor."),
all_pages: bool = Query(
True,
description="Follow paging.next and return every page (default, capped "
"by META_MAX_PAGES). Set false for a single raw page.",
),
creds: MetaCredentials = Depends(get_meta_credentials),
) -> Any:
path = graph_path.strip().lstrip("/")
if not path:
raise UpstreamError("A Graph path is required.", status=400)
# The version comes from config/X-Meta-Api-Version; a version in the path
# would silently override that, so reject it instead of double-prefixing.
first = path.split("/", 1)[0]
if first.startswith("v") and first[1:].replace(".", "").isdigit():
raise UpstreamError(
f"Do not include the API version ('{first}') in the path - it is "
"taken from X-Meta-Api-Version or the service default.",
status=400,
)
# Forward any extra query params verbatim so the full Graph surface stays
# reachable, minus the ones the proxy controls.
params: dict[str, Any] = {
key: value
for key, value in request.query_params.items()
if key not in _RESERVED_PARAMS
}
params.update({"fields": fields, "limit": limit, "after": after})
client = MetaGraphClient(creds, service_name="Meta Graph API")
if all_pages:
return await client.get_all_pages(path, params)
return await client.get(path, params)