first
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user