interloper-api 0.22.0__tar.gz → 0.23.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {interloper_api-0.22.0 → interloper_api-0.23.0}/PKG-INFO +1 -1
- {interloper_api-0.22.0 → interloper_api-0.23.0}/pyproject.toml +1 -1
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/dependencies.py +6 -4
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/catalog.py +10 -10
- interloper_api-0.23.0/src/interloper_api/routes/external/__init__.py +40 -0
- interloper_api-0.23.0/src/interloper_api/routes/external/resolve.py +88 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/resources.py +3 -2
- interloper_api-0.22.0/src/interloper_api/routes/external/__init__.py +0 -56
- interloper_api-0.22.0/src/interloper_api/routes/external/amazon_ads.py +0 -82
- interloper_api-0.22.0/src/interloper_api/routes/external/criteo.py +0 -68
- interloper_api-0.22.0/src/interloper_api/routes/external/facebook_ads.py +0 -55
- interloper_api-0.22.0/src/interloper_api/routes/external/google_ads.py +0 -104
- interloper_api-0.22.0/src/interloper_api/routes/external/google_cloud.py +0 -167
- interloper_api-0.22.0/src/interloper_api/routes/external/impact.py +0 -54
- interloper_api-0.22.0/src/interloper_api/routes/external/pinterest_ads.py +0 -77
- interloper_api-0.22.0/src/interloper_api/routes/external/snapchat_ads.py +0 -86
- interloper_api-0.22.0/src/interloper_api/routes/external/tiktok_ads.py +0 -70
- {interloper_api-0.22.0 → interloper_api-0.23.0}/README.md +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/app.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/email.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/admin.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/assets.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/auth.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/backfills.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/destinations.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/jobs.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/oauth.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/organisations.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/runs.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/sources.py +0 -0
- {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/ws.py +0 -0
|
@@ -63,18 +63,20 @@ def get_store() -> Store:
|
|
|
63
63
|
return _store
|
|
64
64
|
|
|
65
65
|
|
|
66
|
-
def get_catalog() ->
|
|
67
|
-
"""Return the global
|
|
66
|
+
def get_catalog() -> Catalog:
|
|
67
|
+
"""Return the global ``Catalog`` instance.
|
|
68
|
+
|
|
69
|
+
Routes that need the serialized form call ``.dump()`` themselves.
|
|
68
70
|
|
|
69
71
|
Returns:
|
|
70
|
-
|
|
72
|
+
The Catalog instance.
|
|
71
73
|
|
|
72
74
|
Raises:
|
|
73
75
|
RuntimeError: If the catalog has not been set.
|
|
74
76
|
"""
|
|
75
77
|
if _catalog is None:
|
|
76
78
|
raise RuntimeError("Catalog not initialized. Call set_catalog() first.")
|
|
77
|
-
return _catalog
|
|
79
|
+
return _catalog
|
|
78
80
|
|
|
79
81
|
|
|
80
82
|
def get_auth_config() -> Any:
|
|
@@ -4,7 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
-
from fastapi import APIRouter, Depends
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
8
|
+
from interloper.catalog.base import Catalog
|
|
8
9
|
|
|
9
10
|
from interloper_api.dependencies import get_catalog
|
|
10
11
|
|
|
@@ -12,13 +13,13 @@ router = APIRouter()
|
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
@router.get("/")
|
|
15
|
-
def list_catalog(catalog:
|
|
16
|
+
def list_catalog(catalog: Catalog = Depends(get_catalog)) -> dict[str, Any]:
|
|
16
17
|
"""Return the full catalog."""
|
|
17
|
-
return catalog
|
|
18
|
+
return catalog.dump()
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
@router.get("/{key}")
|
|
21
|
-
def get_definition(key: str, catalog:
|
|
22
|
+
def get_definition(key: str, catalog: Catalog = Depends(get_catalog)) -> dict[str, Any]:
|
|
22
23
|
"""Return a single component definition by key.
|
|
23
24
|
|
|
24
25
|
Args:
|
|
@@ -28,19 +29,18 @@ def get_definition(key: str, catalog: dict[str, Any] = Depends(get_catalog)) ->
|
|
|
28
29
|
Raises:
|
|
29
30
|
HTTPException: If the key is not found.
|
|
30
31
|
"""
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if key not in catalog:
|
|
32
|
+
defn = catalog.get(key)
|
|
33
|
+
if defn is None:
|
|
34
34
|
raise HTTPException(status_code=404, detail=f"Component '{key}' not found in catalog")
|
|
35
|
-
return
|
|
35
|
+
return defn.model_dump(mode="json")
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
@router.get("/kind/{kind}")
|
|
39
|
-
def list_by_kind(kind: str, catalog:
|
|
39
|
+
def list_by_kind(kind: str, catalog: Catalog = Depends(get_catalog)) -> dict[str, Any]:
|
|
40
40
|
"""Return all catalog entries matching a kind (source, asset, resource, destination).
|
|
41
41
|
|
|
42
42
|
Args:
|
|
43
43
|
kind: The component kind to filter by.
|
|
44
44
|
catalog: Injected catalog.
|
|
45
45
|
"""
|
|
46
|
-
return {k: v for k, v in catalog.items() if v.
|
|
46
|
+
return {k: v.model_dump(mode="json") for k, v in catalog.components.items() if v.kind == kind}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""External provider API routes.
|
|
2
|
+
|
|
3
|
+
FetchField options are resolved by a single generic endpoint (``resolve``);
|
|
4
|
+
there are no per-provider routes. Each connector exposes its lookups as
|
|
5
|
+
``@fetch_field_provider`` methods on its connection class, which the resolver calls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from fastapi import APIRouter, HTTPException
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
router = APIRouter()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def handle_error(error: Exception, context: str) -> None:
|
|
21
|
+
"""Map external API errors to appropriate HTTP responses."""
|
|
22
|
+
logger.error("Error %s: %s", context, error)
|
|
23
|
+
|
|
24
|
+
if isinstance(error, httpx.HTTPStatusError):
|
|
25
|
+
status = error.response.status_code
|
|
26
|
+
if status in (401, 403):
|
|
27
|
+
raise HTTPException(status_code=status, detail=f"Authorization failed while {context}.")
|
|
28
|
+
if status == 404:
|
|
29
|
+
raise HTTPException(status_code=404, detail=f"Resource not found while {context}.")
|
|
30
|
+
|
|
31
|
+
if isinstance(error, HTTPException):
|
|
32
|
+
raise error
|
|
33
|
+
|
|
34
|
+
raise HTTPException(status_code=500, detail=f"Failed {context}.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Import and register the resolver after helpers are defined.
|
|
38
|
+
from interloper_api.routes.external.resolve import sub_router as resolve_router # noqa: E402
|
|
39
|
+
|
|
40
|
+
router.include_router(resolve_router)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Generic FetchField resolver.
|
|
2
|
+
|
|
3
|
+
Replaces the hand-written per-provider ``/external/<provider>/<x>`` routes
|
|
4
|
+
for fields declared with ``FetchField(provider="<slot>.<method>")``. One
|
|
5
|
+
endpoint resolves any such field by:
|
|
6
|
+
|
|
7
|
+
1. Looking up the component definition in the catalog (authoritative — the
|
|
8
|
+
provider reference comes from the server's schema, never the client).
|
|
9
|
+
2. Importing the component class and reading the resource class in ``<slot>``.
|
|
10
|
+
3. Instantiating that resource from the credentials the form already holds.
|
|
11
|
+
4. Calling the ``@fetch_field_provider`` method ``<method>`` on it.
|
|
12
|
+
|
|
13
|
+
The ``@fetch_field_provider`` marker is the allowlist: only methods opted in that
|
|
14
|
+
way may be invoked, so the browser cannot call arbitrary attributes.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
22
|
+
from interloper.catalog.base import Catalog
|
|
23
|
+
from interloper.resource.fields import is_fetch_field_provider
|
|
24
|
+
from interloper.utils.concurrency import invoke
|
|
25
|
+
from interloper.utils.imports import import_from_path
|
|
26
|
+
from interloper_db import Profile
|
|
27
|
+
from pydantic import BaseModel
|
|
28
|
+
|
|
29
|
+
from interloper_api.dependencies import get_catalog, require_viewer
|
|
30
|
+
from interloper_api.routes.external import handle_error
|
|
31
|
+
|
|
32
|
+
sub_router = APIRouter()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ResolveRequest(BaseModel):
|
|
36
|
+
"""A request to resolve one provider-backed FetchField's options."""
|
|
37
|
+
|
|
38
|
+
component_key: str
|
|
39
|
+
field: str
|
|
40
|
+
# Credentials per resource slot, e.g. {"connection": {"access_token": ...}}.
|
|
41
|
+
deps: dict[str, dict[str, Any]] = {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@sub_router.post("/resolve")
|
|
45
|
+
async def resolve_fetch_field(
|
|
46
|
+
body: ResolveRequest,
|
|
47
|
+
catalog: Catalog = Depends(get_catalog),
|
|
48
|
+
_user: Profile = Depends(require_viewer),
|
|
49
|
+
) -> list[dict[str, Any]]:
|
|
50
|
+
"""Resolve the options for a ``FetchField(provider=...)`` field."""
|
|
51
|
+
defn = catalog.get(body.component_key)
|
|
52
|
+
if defn is None:
|
|
53
|
+
raise HTTPException(status_code=404, detail=f"Unknown component '{body.component_key}'")
|
|
54
|
+
|
|
55
|
+
# Read the provider from the server's own schema, so the client cannot
|
|
56
|
+
# redirect the call to an arbitrary method.
|
|
57
|
+
prop = getattr(defn, "config_schema", {}).get("properties", {}).get(body.field, {})
|
|
58
|
+
provider = prop.get("x-fetch", {}).get("provider")
|
|
59
|
+
if not provider:
|
|
60
|
+
raise HTTPException(
|
|
61
|
+
status_code=400,
|
|
62
|
+
detail=f"Field '{body.field}' on '{body.component_key}' is not a provider-backed FetchField",
|
|
63
|
+
)
|
|
64
|
+
slot, _, method = str(provider).partition(".")
|
|
65
|
+
|
|
66
|
+
component_cls = import_from_path(defn.path)
|
|
67
|
+
resource_cls = getattr(component_cls, "resource_types", {}).get(slot)
|
|
68
|
+
if resource_cls is None:
|
|
69
|
+
raise HTTPException(status_code=400, detail=f"Resource slot '{slot}' not found on '{body.component_key}'")
|
|
70
|
+
|
|
71
|
+
# Only pass through fields the resource actually declares — the form may
|
|
72
|
+
# carry extra markers (e.g. an internal id) that the model would reject.
|
|
73
|
+
raw = body.deps.get(slot, {})
|
|
74
|
+
creds = {k: v for k, v in raw.items() if k in resource_cls.model_fields}
|
|
75
|
+
resource = resource_cls(**creds)
|
|
76
|
+
|
|
77
|
+
fn = getattr(resource, method, None)
|
|
78
|
+
if not is_fetch_field_provider(fn):
|
|
79
|
+
# Should never happen — validated at catalog build — but guard anyway.
|
|
80
|
+
raise HTTPException(status_code=403, detail=f"'{provider}' is not a fetch provider")
|
|
81
|
+
assert fn is not None # narrowed by the is_fetch_field_provider guard above
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
result = await invoke(fn)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
handle_error(exc, f"resolving {body.component_key}.{body.field}")
|
|
87
|
+
return []
|
|
88
|
+
return list(result or [])
|
|
@@ -6,6 +6,7 @@ from typing import Any
|
|
|
6
6
|
from uuid import UUID
|
|
7
7
|
|
|
8
8
|
from fastapi import APIRouter, Depends, HTTPException
|
|
9
|
+
from interloper.catalog.base import Catalog
|
|
9
10
|
from interloper.errors import NotFoundError
|
|
10
11
|
from interloper_db import Profile, Store
|
|
11
12
|
from interloper_db.models import Resource
|
|
@@ -84,7 +85,7 @@ def _load_authorized_resource(resource_id: UUID, user: Profile, store: Store, *,
|
|
|
84
85
|
|
|
85
86
|
@router.get("/kinds")
|
|
86
87
|
def list_resource_kinds(
|
|
87
|
-
catalog:
|
|
88
|
+
catalog: Catalog = Depends(get_catalog),
|
|
88
89
|
) -> list[str]:
|
|
89
90
|
"""Return distinct resource kinds from the catalog.
|
|
90
91
|
|
|
@@ -92,7 +93,7 @@ def list_resource_kinds(
|
|
|
92
93
|
(source, asset, destination). Currently this yields kinds like
|
|
93
94
|
``connection`` and ``config``.
|
|
94
95
|
"""
|
|
95
|
-
kinds = {defn
|
|
96
|
+
kinds = {defn.kind for defn in catalog.components.values() if defn.kind and defn.kind not in _NON_RESOURCE_KINDS}
|
|
96
97
|
return sorted(kinds)
|
|
97
98
|
|
|
98
99
|
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
"""External provider API routes for FetchField resolution.
|
|
2
|
-
|
|
3
|
-
Each submodule contains routes for a specific external provider.
|
|
4
|
-
The combined ``router`` is re-exported here so ``app.py`` can
|
|
5
|
-
import it unchanged.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import logging
|
|
11
|
-
|
|
12
|
-
import httpx
|
|
13
|
-
from fastapi import APIRouter, HTTPException
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
router = APIRouter()
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def handle_error(error: Exception, context: str) -> None:
|
|
21
|
-
"""Map external API errors to appropriate HTTP responses."""
|
|
22
|
-
logger.error("Error %s: %s", context, error)
|
|
23
|
-
|
|
24
|
-
if isinstance(error, httpx.HTTPStatusError):
|
|
25
|
-
status = error.response.status_code
|
|
26
|
-
if status in (401, 403):
|
|
27
|
-
raise HTTPException(status_code=status, detail=f"Authorization failed while {context}.")
|
|
28
|
-
if status == 404:
|
|
29
|
-
raise HTTPException(status_code=404, detail=f"Resource not found while {context}.")
|
|
30
|
-
|
|
31
|
-
if isinstance(error, HTTPException):
|
|
32
|
-
raise error
|
|
33
|
-
|
|
34
|
-
raise HTTPException(status_code=500, detail=f"Failed {context}.")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
# Import and register sub-routers after helpers are defined.
|
|
38
|
-
from interloper_api.routes.external.amazon_ads import sub_router as amazon_ads_router # noqa: E402
|
|
39
|
-
from interloper_api.routes.external.criteo import sub_router as criteo_router # noqa: E402
|
|
40
|
-
from interloper_api.routes.external.facebook_ads import sub_router as facebook_ads_router # noqa: E402
|
|
41
|
-
from interloper_api.routes.external.google_ads import sub_router as google_ads_router # noqa: E402
|
|
42
|
-
from interloper_api.routes.external.google_cloud import sub_router as google_cloud_router # noqa: E402
|
|
43
|
-
from interloper_api.routes.external.impact import sub_router as impact_router # noqa: E402
|
|
44
|
-
from interloper_api.routes.external.pinterest_ads import sub_router as pinterest_ads_router # noqa: E402
|
|
45
|
-
from interloper_api.routes.external.snapchat_ads import sub_router as snapchat_ads_router # noqa: E402
|
|
46
|
-
from interloper_api.routes.external.tiktok_ads import sub_router as tiktok_ads_router # noqa: E402
|
|
47
|
-
|
|
48
|
-
router.include_router(amazon_ads_router)
|
|
49
|
-
router.include_router(criteo_router)
|
|
50
|
-
router.include_router(facebook_ads_router)
|
|
51
|
-
router.include_router(google_ads_router)
|
|
52
|
-
router.include_router(google_cloud_router)
|
|
53
|
-
router.include_router(impact_router)
|
|
54
|
-
router.include_router(pinterest_ads_router)
|
|
55
|
-
router.include_router(snapchat_ads_router)
|
|
56
|
-
router.include_router(tiktok_ads_router)
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
"""Amazon Ads external API routes."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
from fastapi import APIRouter, Depends
|
|
7
|
-
from interloper_db import Profile
|
|
8
|
-
from pydantic import BaseModel
|
|
9
|
-
|
|
10
|
-
from interloper_api.dependencies import require_viewer
|
|
11
|
-
from interloper_api.routes.external import handle_error
|
|
12
|
-
|
|
13
|
-
sub_router = APIRouter()
|
|
14
|
-
|
|
15
|
-
_API_URLS: dict[str, str] = {
|
|
16
|
-
"EU": "https://advertising-api-eu.amazon.com",
|
|
17
|
-
"FE": "https://advertising-api-fe.amazon.com",
|
|
18
|
-
"NA": "https://advertising-api.amazon.com",
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
_AUTH_URLS: dict[str, str] = {
|
|
22
|
-
"EU": "https://api.amazon.co.uk",
|
|
23
|
-
"FE": "https://api.amazon.co.jp",
|
|
24
|
-
"NA": "https://api.amazon.com",
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class AmazonAdsConnectionRequest(BaseModel):
|
|
29
|
-
"""Amazon Ads connection credentials (matches AmazonAdsConnection fields)."""
|
|
30
|
-
|
|
31
|
-
location: str = "NA"
|
|
32
|
-
client_id: str
|
|
33
|
-
client_secret: str
|
|
34
|
-
refresh_token: str
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@sub_router.post("/amazon-ads/profiles")
|
|
38
|
-
async def amazon_ads_profiles(
|
|
39
|
-
body: AmazonAdsConnectionRequest,
|
|
40
|
-
_user: Profile = Depends(require_viewer),
|
|
41
|
-
) -> list[dict[str, str]]:
|
|
42
|
-
"""Fetch Amazon Ads advertising profiles for a connection."""
|
|
43
|
-
api_url = _API_URLS.get(body.location, _API_URLS["NA"])
|
|
44
|
-
auth_url = _AUTH_URLS.get(body.location, _AUTH_URLS["NA"])
|
|
45
|
-
|
|
46
|
-
try:
|
|
47
|
-
async with httpx.AsyncClient(timeout=30) as client:
|
|
48
|
-
token_resp = await client.post(
|
|
49
|
-
f"{auth_url}/auth/o2/token",
|
|
50
|
-
data={
|
|
51
|
-
"grant_type": "refresh_token",
|
|
52
|
-
"refresh_token": body.refresh_token,
|
|
53
|
-
"client_id": body.client_id,
|
|
54
|
-
"client_secret": body.client_secret,
|
|
55
|
-
},
|
|
56
|
-
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
57
|
-
)
|
|
58
|
-
token_resp.raise_for_status()
|
|
59
|
-
access_token = token_resp.json()["access_token"]
|
|
60
|
-
|
|
61
|
-
profiles_resp = await client.get(
|
|
62
|
-
f"{api_url}/v2/profiles",
|
|
63
|
-
headers={
|
|
64
|
-
"Authorization": f"Bearer {access_token}",
|
|
65
|
-
"Amazon-Advertising-API-ClientId": body.client_id,
|
|
66
|
-
},
|
|
67
|
-
)
|
|
68
|
-
profiles_resp.raise_for_status()
|
|
69
|
-
profiles = profiles_resp.json()
|
|
70
|
-
|
|
71
|
-
return [
|
|
72
|
-
{
|
|
73
|
-
"profile_id": str(p["profileId"]),
|
|
74
|
-
"name": f"{p['accountInfo']['name']} ({p['countryCode']})",
|
|
75
|
-
"account_id": p["accountInfo"]["id"],
|
|
76
|
-
"country_code": p["countryCode"],
|
|
77
|
-
}
|
|
78
|
-
for p in profiles
|
|
79
|
-
]
|
|
80
|
-
except Exception as exc:
|
|
81
|
-
handle_error(exc, "fetching Amazon Ads profiles")
|
|
82
|
-
return [] # unreachable, but satisfies type checker
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
"""Criteo external API routes."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
from fastapi import APIRouter, Depends
|
|
7
|
-
from interloper_db import Profile
|
|
8
|
-
from pydantic import BaseModel
|
|
9
|
-
|
|
10
|
-
from interloper_api.dependencies import require_viewer
|
|
11
|
-
from interloper_api.routes.external import handle_error
|
|
12
|
-
|
|
13
|
-
sub_router = APIRouter()
|
|
14
|
-
|
|
15
|
-
_BASE_URL = "https://api.criteo.com"
|
|
16
|
-
_TOKEN_URL = f"{_BASE_URL}/oauth2/token"
|
|
17
|
-
_ADVERTISERS_URL = f"{_BASE_URL}/2025-04/advertisers/me"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class CriteoConnectionRequest(BaseModel):
|
|
21
|
-
"""Criteo connection credentials (matches CriteoConnection fields)."""
|
|
22
|
-
|
|
23
|
-
client_id: str
|
|
24
|
-
client_secret: str
|
|
25
|
-
refresh_token: str
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
async def _get_access_token(client: httpx.AsyncClient, body: CriteoConnectionRequest) -> str:
|
|
29
|
-
"""Exchange the refresh token for an access token."""
|
|
30
|
-
resp = await client.post(
|
|
31
|
-
_TOKEN_URL,
|
|
32
|
-
data={
|
|
33
|
-
"grant_type": "refresh_token",
|
|
34
|
-
"client_id": body.client_id,
|
|
35
|
-
"client_secret": body.client_secret,
|
|
36
|
-
"refresh_token": body.refresh_token,
|
|
37
|
-
},
|
|
38
|
-
)
|
|
39
|
-
resp.raise_for_status()
|
|
40
|
-
return resp.json()["access_token"]
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
async def _list_advertisers(client: httpx.AsyncClient, body: CriteoConnectionRequest) -> list[dict[str, str]]:
|
|
44
|
-
"""List the advertisers the connection can access, sorted by name."""
|
|
45
|
-
token = await _get_access_token(client, body)
|
|
46
|
-
resp = await client.get(_ADVERTISERS_URL, headers={"Authorization": f"Bearer {token}"})
|
|
47
|
-
resp.raise_for_status()
|
|
48
|
-
|
|
49
|
-
advertisers = resp.json().get("data", [])
|
|
50
|
-
results = [
|
|
51
|
-
{"id": a["id"], "name": a.get("attributes", {}).get("advertiserName") or a["id"]}
|
|
52
|
-
for a in advertisers
|
|
53
|
-
]
|
|
54
|
-
return sorted(results, key=lambda a: a["name"].lower())
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@sub_router.post("/criteo/advertisers")
|
|
58
|
-
async def criteo_advertisers(
|
|
59
|
-
body: CriteoConnectionRequest,
|
|
60
|
-
_user: Profile = Depends(require_viewer),
|
|
61
|
-
) -> list[dict[str, str]]:
|
|
62
|
-
"""Fetch the Criteo advertisers accessible by the connection."""
|
|
63
|
-
try:
|
|
64
|
-
async with httpx.AsyncClient(timeout=30) as client:
|
|
65
|
-
return await _list_advertisers(client, body)
|
|
66
|
-
except Exception as exc:
|
|
67
|
-
handle_error(exc, "fetching Criteo advertisers")
|
|
68
|
-
return []
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
"""Facebook Ads external API routes."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
from fastapi import APIRouter, Depends
|
|
7
|
-
from interloper_db import Profile
|
|
8
|
-
from pydantic import BaseModel
|
|
9
|
-
|
|
10
|
-
from interloper_api.dependencies import require_viewer
|
|
11
|
-
from interloper_api.routes.external import handle_error
|
|
12
|
-
|
|
13
|
-
sub_router = APIRouter()
|
|
14
|
-
|
|
15
|
-
_BASE_URL = "https://graph.facebook.com/v21.0"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class FacebookAdsConnectionRequest(BaseModel):
|
|
19
|
-
"""Facebook Ads connection credentials (matches FacebookAdsConnection fields)."""
|
|
20
|
-
|
|
21
|
-
access_token: str
|
|
22
|
-
app_id: str = ""
|
|
23
|
-
app_secret: str = ""
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@sub_router.post("/facebook-ads/accounts")
|
|
27
|
-
async def facebook_ads_accounts(
|
|
28
|
-
body: FacebookAdsConnectionRequest,
|
|
29
|
-
_user: Profile = Depends(require_viewer),
|
|
30
|
-
) -> list[dict[str, str]]:
|
|
31
|
-
"""Fetch Facebook Ads ad accounts accessible by the connection."""
|
|
32
|
-
try:
|
|
33
|
-
async with httpx.AsyncClient(timeout=30) as client:
|
|
34
|
-
resp = await client.get(
|
|
35
|
-
f"{_BASE_URL}/me/adaccounts",
|
|
36
|
-
params={
|
|
37
|
-
"access_token": body.access_token,
|
|
38
|
-
"fields": "account_id,name,account_status",
|
|
39
|
-
"limit": "500",
|
|
40
|
-
},
|
|
41
|
-
)
|
|
42
|
-
resp.raise_for_status()
|
|
43
|
-
data = resp.json().get("data", [])
|
|
44
|
-
|
|
45
|
-
return [
|
|
46
|
-
{
|
|
47
|
-
"account_id": acct["account_id"],
|
|
48
|
-
"name": f"{acct.get('name', acct['account_id'])}",
|
|
49
|
-
}
|
|
50
|
-
for acct in data
|
|
51
|
-
if acct.get("account_status") == 1 # ACTIVE only
|
|
52
|
-
]
|
|
53
|
-
except Exception as exc:
|
|
54
|
-
handle_error(exc, "fetching Facebook Ads accounts")
|
|
55
|
-
return []
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
"""Google Ads external API routes."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
from fastapi import APIRouter, Depends
|
|
7
|
-
from interloper_db import Profile
|
|
8
|
-
from pydantic import BaseModel
|
|
9
|
-
|
|
10
|
-
from interloper_api.dependencies import require_viewer
|
|
11
|
-
from interloper_api.routes.external import handle_error
|
|
12
|
-
|
|
13
|
-
sub_router = APIRouter()
|
|
14
|
-
|
|
15
|
-
_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
16
|
-
_BASE_URL = "https://googleads.googleapis.com/v20"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class GoogleAdsConnectionRequest(BaseModel):
|
|
20
|
-
"""Google Ads connection credentials (matches GoogleAdsConnection fields)."""
|
|
21
|
-
|
|
22
|
-
client_id: str
|
|
23
|
-
client_secret: str
|
|
24
|
-
developer_token: str
|
|
25
|
-
refresh_token: str
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
async def _get_access_token(client: httpx.AsyncClient, body: GoogleAdsConnectionRequest) -> str:
|
|
29
|
-
"""Exchange the refresh token for an access token."""
|
|
30
|
-
resp = await client.post(
|
|
31
|
-
_TOKEN_URL,
|
|
32
|
-
data={
|
|
33
|
-
"grant_type": "refresh_token",
|
|
34
|
-
"refresh_token": body.refresh_token,
|
|
35
|
-
"client_id": body.client_id,
|
|
36
|
-
"client_secret": body.client_secret,
|
|
37
|
-
},
|
|
38
|
-
)
|
|
39
|
-
resp.raise_for_status()
|
|
40
|
-
return resp.json()["access_token"]
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _auth_headers(access_token: str, developer_token: str) -> dict[str, str]:
|
|
44
|
-
return {
|
|
45
|
-
"Authorization": f"Bearer {access_token}",
|
|
46
|
-
"developer-token": developer_token,
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@sub_router.post("/google-ads/customers")
|
|
51
|
-
async def google_ads_customers(
|
|
52
|
-
body: GoogleAdsConnectionRequest,
|
|
53
|
-
_user: Profile = Depends(require_viewer),
|
|
54
|
-
) -> list[dict[str, str]]:
|
|
55
|
-
"""Fetch Google Ads customer accounts accessible by the connection."""
|
|
56
|
-
try:
|
|
57
|
-
async with httpx.AsyncClient(timeout=30) as client:
|
|
58
|
-
access_token = await _get_access_token(client, body)
|
|
59
|
-
headers = _auth_headers(access_token, body.developer_token)
|
|
60
|
-
|
|
61
|
-
# Step 1: List accessible customer resource names.
|
|
62
|
-
list_resp = await client.get(
|
|
63
|
-
f"{_BASE_URL}/customers:listAccessibleCustomers",
|
|
64
|
-
headers=headers,
|
|
65
|
-
)
|
|
66
|
-
list_resp.raise_for_status()
|
|
67
|
-
resource_names: list[str] = list_resp.json().get("resourceNames", [])
|
|
68
|
-
|
|
69
|
-
# Step 2: Fetch descriptive name for each customer.
|
|
70
|
-
results: list[dict[str, str]] = []
|
|
71
|
-
for rn in resource_names:
|
|
72
|
-
# rn is like "customers/1234567890"
|
|
73
|
-
customer_id = rn.split("/")[-1]
|
|
74
|
-
query = (
|
|
75
|
-
"SELECT customer.id, customer.descriptive_name, customer.status "
|
|
76
|
-
"FROM customer LIMIT 1"
|
|
77
|
-
)
|
|
78
|
-
try:
|
|
79
|
-
search_resp = await client.post(
|
|
80
|
-
f"{_BASE_URL}/{rn}/googleAds:searchStream",
|
|
81
|
-
headers=headers,
|
|
82
|
-
json={"query": query},
|
|
83
|
-
)
|
|
84
|
-
search_resp.raise_for_status()
|
|
85
|
-
batches = search_resp.json()
|
|
86
|
-
for batch in batches:
|
|
87
|
-
for row in batch.get("results", []):
|
|
88
|
-
customer = row.get("customer", {})
|
|
89
|
-
name = customer.get("descriptiveName", customer_id)
|
|
90
|
-
results.append({
|
|
91
|
-
"customer_id": customer_id,
|
|
92
|
-
"name": f"{name} ({customer_id})",
|
|
93
|
-
})
|
|
94
|
-
except httpx.HTTPStatusError:
|
|
95
|
-
# Some customers may not be queryable (suspended, etc.)
|
|
96
|
-
results.append({
|
|
97
|
-
"customer_id": customer_id,
|
|
98
|
-
"name": customer_id,
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
return results
|
|
102
|
-
except Exception as exc:
|
|
103
|
-
handle_error(exc, "fetching Google Ads customers")
|
|
104
|
-
return []
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
"""Google Cloud external API routes."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import time
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
import httpx
|
|
10
|
-
from fastapi import APIRouter, Depends, HTTPException
|
|
11
|
-
from google.auth import crypt, jwt
|
|
12
|
-
from interloper_db import Profile
|
|
13
|
-
from pydantic import BaseModel, field_validator
|
|
14
|
-
|
|
15
|
-
from interloper_api.dependencies import require_viewer
|
|
16
|
-
from interloper_api.routes.external import handle_error
|
|
17
|
-
|
|
18
|
-
sub_router = APIRouter()
|
|
19
|
-
|
|
20
|
-
_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
21
|
-
# BigQuery's own projects.list: returns the projects the credential holds a
|
|
22
|
-
# BigQuery role on -- exactly the candidates for a BigQuery destination --
|
|
23
|
-
# and only requires the BigQuery API, which is necessarily enabled wherever
|
|
24
|
-
# the destination can work (unlike the Cloud Resource Manager API, which is
|
|
25
|
-
# frequently disabled).
|
|
26
|
-
_PROJECTS_URL = "https://bigquery.googleapis.com/bigquery/v2/projects"
|
|
27
|
-
_SCOPE = "https://www.googleapis.com/auth/bigquery.readonly"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class GoogleCloudConnectionRequest(BaseModel):
|
|
31
|
-
"""Google Cloud connection credentials (matches GoogleCloudConnection fields)."""
|
|
32
|
-
|
|
33
|
-
service_account_key: str
|
|
34
|
-
|
|
35
|
-
@field_validator("service_account_key", mode="before")
|
|
36
|
-
@classmethod
|
|
37
|
-
def _serialize_key(cls, v: object) -> object:
|
|
38
|
-
if isinstance(v, dict):
|
|
39
|
-
return json.dumps(v)
|
|
40
|
-
return v
|
|
41
|
-
|
|
42
|
-
@property
|
|
43
|
-
def key_info(self) -> dict[str, Any]:
|
|
44
|
-
"""The parsed service account key.
|
|
45
|
-
|
|
46
|
-
Returns:
|
|
47
|
-
The key as a dict.
|
|
48
|
-
|
|
49
|
-
Raises:
|
|
50
|
-
HTTPException: If the key is not valid JSON.
|
|
51
|
-
"""
|
|
52
|
-
try:
|
|
53
|
-
return json.loads(self.service_account_key)
|
|
54
|
-
except json.JSONDecodeError:
|
|
55
|
-
raise HTTPException(status_code=400, detail="service_account_key is not valid JSON.")
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _make_assertion(key_info: dict[str, Any]) -> str:
|
|
59
|
-
"""Build a signed JWT-bearer assertion for the service account.
|
|
60
|
-
|
|
61
|
-
Only the signing comes from google-auth; the token exchange itself goes
|
|
62
|
-
through httpx like every other external route.
|
|
63
|
-
|
|
64
|
-
Args:
|
|
65
|
-
key_info: The parsed service account key.
|
|
66
|
-
|
|
67
|
-
Returns:
|
|
68
|
-
The signed JWT assertion.
|
|
69
|
-
"""
|
|
70
|
-
signer = crypt.RSASigner.from_service_account_info(key_info)
|
|
71
|
-
now = int(time.time())
|
|
72
|
-
payload = {
|
|
73
|
-
"iss": key_info["client_email"],
|
|
74
|
-
"scope": _SCOPE,
|
|
75
|
-
"aud": _TOKEN_URL,
|
|
76
|
-
"iat": now,
|
|
77
|
-
"exp": now + 600,
|
|
78
|
-
}
|
|
79
|
-
return jwt.encode(signer, payload).decode()
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
async def _get_access_token(client: httpx.AsyncClient, key_info: dict[str, Any]) -> str:
|
|
83
|
-
"""Exchange a service account JWT assertion for an access token."""
|
|
84
|
-
resp = await client.post(
|
|
85
|
-
_TOKEN_URL,
|
|
86
|
-
data={
|
|
87
|
-
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
88
|
-
"assertion": _make_assertion(key_info),
|
|
89
|
-
},
|
|
90
|
-
)
|
|
91
|
-
resp.raise_for_status()
|
|
92
|
-
return resp.json()["access_token"]
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
async def _list_projects(client: httpx.AsyncClient, access_token: str) -> list[dict[str, str]]:
|
|
96
|
-
"""List the projects the credential has BigQuery access to, following pagination.
|
|
97
|
-
|
|
98
|
-
Returns:
|
|
99
|
-
Project options with ``project_id`` and a display ``name``.
|
|
100
|
-
"""
|
|
101
|
-
results: list[dict[str, str]] = []
|
|
102
|
-
page_token: str | None = None
|
|
103
|
-
while True:
|
|
104
|
-
params: dict[str, str] = {"maxResults": "500"}
|
|
105
|
-
if page_token:
|
|
106
|
-
params["pageToken"] = page_token
|
|
107
|
-
resp = await client.get(
|
|
108
|
-
_PROJECTS_URL,
|
|
109
|
-
params=params,
|
|
110
|
-
headers={"Authorization": f"Bearer {access_token}"},
|
|
111
|
-
)
|
|
112
|
-
resp.raise_for_status()
|
|
113
|
-
data = resp.json()
|
|
114
|
-
for project in data.get("projects", []):
|
|
115
|
-
project_id = project["id"]
|
|
116
|
-
name = project.get("friendlyName") or project_id
|
|
117
|
-
results.append({"project_id": project_id, "name": f"{name} ({project_id})"})
|
|
118
|
-
page_token = data.get("nextPageToken")
|
|
119
|
-
if not page_token:
|
|
120
|
-
break
|
|
121
|
-
return sorted(results, key=lambda p: p["name"].lower())
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def _upstream_detail(exc: httpx.HTTPStatusError) -> str:
|
|
125
|
-
"""Extract the human-readable error message from a Google error response.
|
|
126
|
-
|
|
127
|
-
The token endpoint answers ``{"error": ..., "error_description": ...}``;
|
|
128
|
-
the Cloud Resource Manager answers ``{"error": {"message": ...}}``.
|
|
129
|
-
|
|
130
|
-
Returns:
|
|
131
|
-
Google's error message, or the raw body as a fallback.
|
|
132
|
-
"""
|
|
133
|
-
try:
|
|
134
|
-
payload = exc.response.json()
|
|
135
|
-
except ValueError:
|
|
136
|
-
return exc.response.text[:200]
|
|
137
|
-
error = payload.get("error")
|
|
138
|
-
if isinstance(error, dict):
|
|
139
|
-
return str(error.get("message") or error)
|
|
140
|
-
description = payload.get("error_description")
|
|
141
|
-
return str(description or error or exc.response.text[:200])
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
@sub_router.post("/google-cloud/projects")
|
|
145
|
-
async def google_cloud_projects(
|
|
146
|
-
body: GoogleCloudConnectionRequest,
|
|
147
|
-
_user: Profile = Depends(require_viewer),
|
|
148
|
-
) -> list[dict[str, str]]:
|
|
149
|
-
"""Fetch the Google Cloud projects accessible by the connection."""
|
|
150
|
-
key_info = body.key_info
|
|
151
|
-
try:
|
|
152
|
-
async with httpx.AsyncClient(timeout=30) as client:
|
|
153
|
-
access_token = await _get_access_token(client, key_info)
|
|
154
|
-
return await _list_projects(client, access_token)
|
|
155
|
-
except httpx.HTTPStatusError as exc:
|
|
156
|
-
# Surface Google's own message (e.g. "Cloud Resource Manager API has
|
|
157
|
-
# not been used in project ...", "Invalid JWT Signature.") so the
|
|
158
|
-
# form error is actionable, instead of the generic handle_error text.
|
|
159
|
-
status = exc.response.status_code
|
|
160
|
-
detail = _upstream_detail(exc)
|
|
161
|
-
raise HTTPException(
|
|
162
|
-
status_code=status if status in (401, 403, 404) else 502,
|
|
163
|
-
detail=f"Google Cloud error while fetching projects: {detail}",
|
|
164
|
-
)
|
|
165
|
-
except Exception as exc:
|
|
166
|
-
handle_error(exc, "fetching Google Cloud projects")
|
|
167
|
-
return [] # unreachable, but satisfies type checker
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
"""Impact external API routes."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
from fastapi import APIRouter, Depends
|
|
7
|
-
from interloper_db import Profile
|
|
8
|
-
from pydantic import BaseModel
|
|
9
|
-
|
|
10
|
-
from interloper_api.dependencies import require_viewer
|
|
11
|
-
from interloper_api.routes.external import handle_error
|
|
12
|
-
|
|
13
|
-
sub_router = APIRouter()
|
|
14
|
-
|
|
15
|
-
_BASE_URL = "https://api.impact.com"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class ImpactConnectionRequest(BaseModel):
|
|
19
|
-
"""Impact connection credentials (matches ImpactConnection fields)."""
|
|
20
|
-
|
|
21
|
-
account_sid: str
|
|
22
|
-
auth_token: str
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@sub_router.post("/impact/programs")
|
|
26
|
-
async def impact_programs(
|
|
27
|
-
body: ImpactConnectionRequest,
|
|
28
|
-
_user: Profile = Depends(require_viewer),
|
|
29
|
-
) -> list[dict[str, str]]:
|
|
30
|
-
"""Fetch Impact programs (campaigns) accessible by the connection."""
|
|
31
|
-
try:
|
|
32
|
-
programs: list[dict[str, str]] = []
|
|
33
|
-
async with httpx.AsyncClient(
|
|
34
|
-
timeout=30,
|
|
35
|
-
auth=httpx.BasicAuth(body.account_sid, body.auth_token),
|
|
36
|
-
headers={"Accept": "application/json"},
|
|
37
|
-
) as client:
|
|
38
|
-
page, num_pages = 1, 1
|
|
39
|
-
while page <= num_pages:
|
|
40
|
-
resp = await client.get(
|
|
41
|
-
f"{_BASE_URL}/Advertisers/{body.account_sid}/Campaigns",
|
|
42
|
-
params={"Page": page},
|
|
43
|
-
)
|
|
44
|
-
resp.raise_for_status()
|
|
45
|
-
data = resp.json()
|
|
46
|
-
num_pages = int(data["@numpages"])
|
|
47
|
-
for campaign in data.get("Campaigns", []):
|
|
48
|
-
programs.append({"Id": campaign["Id"], "Name": campaign.get("Name", campaign["Id"])})
|
|
49
|
-
page += 1
|
|
50
|
-
|
|
51
|
-
return programs
|
|
52
|
-
except Exception as exc:
|
|
53
|
-
handle_error(exc, "fetching Impact programs")
|
|
54
|
-
return []
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
"""Pinterest Ads external API routes."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
from fastapi import APIRouter, Depends
|
|
7
|
-
from interloper_db import Profile
|
|
8
|
-
from pydantic import BaseModel
|
|
9
|
-
|
|
10
|
-
from interloper_api.dependencies import require_viewer
|
|
11
|
-
from interloper_api.routes.external import handle_error
|
|
12
|
-
|
|
13
|
-
sub_router = APIRouter()
|
|
14
|
-
|
|
15
|
-
_BASE_URL = "https://api.pinterest.com/v5"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class PinterestAdsConnectionRequest(BaseModel):
|
|
19
|
-
"""Pinterest Ads connection credentials (matches PinterestAdsConnection fields)."""
|
|
20
|
-
|
|
21
|
-
client_id: str
|
|
22
|
-
client_secret: str
|
|
23
|
-
refresh_token: str
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
async def _get_access_token(body: PinterestAdsConnectionRequest) -> str:
|
|
27
|
-
"""Exchange refresh token for an access token using HTTP Basic auth."""
|
|
28
|
-
async with httpx.AsyncClient(timeout=30) as client:
|
|
29
|
-
resp = await client.post(
|
|
30
|
-
f"{_BASE_URL}/oauth/token",
|
|
31
|
-
auth=(body.client_id, body.client_secret),
|
|
32
|
-
data={
|
|
33
|
-
"grant_type": "refresh_token",
|
|
34
|
-
"refresh_token": body.refresh_token,
|
|
35
|
-
},
|
|
36
|
-
)
|
|
37
|
-
resp.raise_for_status()
|
|
38
|
-
return resp.json()["access_token"]
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@sub_router.post("/pinterest-ads/accounts")
|
|
42
|
-
async def pinterest_ads_accounts(
|
|
43
|
-
body: PinterestAdsConnectionRequest,
|
|
44
|
-
_user: Profile = Depends(require_viewer),
|
|
45
|
-
) -> list[dict[str, str]]:
|
|
46
|
-
"""Fetch Pinterest Ads ad accounts accessible by the connection."""
|
|
47
|
-
try:
|
|
48
|
-
token = await _get_access_token(body)
|
|
49
|
-
headers = {"Authorization": f"Bearer {token}"}
|
|
50
|
-
|
|
51
|
-
accounts: list[dict[str, str]] = []
|
|
52
|
-
bookmark: str | None = None
|
|
53
|
-
|
|
54
|
-
async with httpx.AsyncClient(timeout=30, headers=headers) as client:
|
|
55
|
-
while True:
|
|
56
|
-
params: dict[str, str] = {"page_size": "100"}
|
|
57
|
-
if bookmark:
|
|
58
|
-
params["bookmark"] = bookmark
|
|
59
|
-
|
|
60
|
-
resp = await client.get(f"{_BASE_URL}/ad_accounts", params=params)
|
|
61
|
-
resp.raise_for_status()
|
|
62
|
-
data = resp.json()
|
|
63
|
-
|
|
64
|
-
for acct in data.get("items", []):
|
|
65
|
-
accounts.append({
|
|
66
|
-
"id": acct["id"],
|
|
67
|
-
"name": acct.get("name", acct["id"]),
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
bookmark = data.get("bookmark")
|
|
71
|
-
if not bookmark:
|
|
72
|
-
break
|
|
73
|
-
|
|
74
|
-
return accounts
|
|
75
|
-
except Exception as exc:
|
|
76
|
-
handle_error(exc, "fetching Pinterest Ads accounts")
|
|
77
|
-
return []
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
"""Snapchat Ads external API routes."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
from fastapi import APIRouter, Depends
|
|
7
|
-
from interloper_db import Profile
|
|
8
|
-
from pydantic import BaseModel
|
|
9
|
-
|
|
10
|
-
from interloper_api.dependencies import require_viewer
|
|
11
|
-
from interloper_api.routes.external import handle_error
|
|
12
|
-
|
|
13
|
-
sub_router = APIRouter()
|
|
14
|
-
|
|
15
|
-
_BASE_URL = "https://adsapi.snapchat.com/v1"
|
|
16
|
-
_TOKEN_URL = "https://accounts.snapchat.com/login/oauth2/access_token"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class SnapchatAdsConnectionRequest(BaseModel):
|
|
20
|
-
"""Snapchat Ads connection credentials (matches SnapchatAdsConnection fields)."""
|
|
21
|
-
|
|
22
|
-
client_id: str
|
|
23
|
-
client_secret: str
|
|
24
|
-
refresh_token: str
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
async def _get_access_token(body: SnapchatAdsConnectionRequest) -> str:
|
|
28
|
-
"""Exchange refresh token for an access token."""
|
|
29
|
-
async with httpx.AsyncClient(timeout=30) as client:
|
|
30
|
-
resp = await client.post(
|
|
31
|
-
_TOKEN_URL,
|
|
32
|
-
data={
|
|
33
|
-
"grant_type": "refresh_token",
|
|
34
|
-
"client_id": body.client_id,
|
|
35
|
-
"client_secret": body.client_secret,
|
|
36
|
-
"refresh_token": body.refresh_token,
|
|
37
|
-
},
|
|
38
|
-
)
|
|
39
|
-
resp.raise_for_status()
|
|
40
|
-
return resp.json()["access_token"]
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@sub_router.post("/snapchat-ads/ad-accounts")
|
|
44
|
-
async def snapchat_ads_accounts(
|
|
45
|
-
body: SnapchatAdsConnectionRequest,
|
|
46
|
-
_user: Profile = Depends(require_viewer),
|
|
47
|
-
) -> list[dict[str, str]]:
|
|
48
|
-
"""Fetch Snapchat Ads ad accounts accessible by the connection.
|
|
49
|
-
|
|
50
|
-
Flow: authenticate → list organizations → list ad accounts per org.
|
|
51
|
-
"""
|
|
52
|
-
try:
|
|
53
|
-
token = await _get_access_token(body)
|
|
54
|
-
headers = {"Authorization": f"Bearer {token}"}
|
|
55
|
-
|
|
56
|
-
async with httpx.AsyncClient(timeout=30, headers=headers) as client:
|
|
57
|
-
# 1. Get organizations
|
|
58
|
-
org_resp = await client.get(f"{_BASE_URL}/me/organizations")
|
|
59
|
-
org_resp.raise_for_status()
|
|
60
|
-
orgs = org_resp.json().get("organizations", [])
|
|
61
|
-
|
|
62
|
-
# 2. Get ad accounts for each organization
|
|
63
|
-
accounts: list[dict[str, str]] = []
|
|
64
|
-
for org_wrapper in orgs:
|
|
65
|
-
org = org_wrapper.get("organization", {})
|
|
66
|
-
org_id = org.get("id")
|
|
67
|
-
if not org_id:
|
|
68
|
-
continue
|
|
69
|
-
|
|
70
|
-
acct_resp = await client.get(f"{_BASE_URL}/organizations/{org_id}/adaccounts")
|
|
71
|
-
acct_resp.raise_for_status()
|
|
72
|
-
ad_accounts = acct_resp.json().get("adaccounts", [])
|
|
73
|
-
|
|
74
|
-
for acct_wrapper in ad_accounts:
|
|
75
|
-
acct = acct_wrapper.get("adaccount", {})
|
|
76
|
-
if acct.get("status") != "ACTIVE":
|
|
77
|
-
continue
|
|
78
|
-
accounts.append({
|
|
79
|
-
"id": acct["id"],
|
|
80
|
-
"name": acct.get("name", acct["id"]),
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
return accounts
|
|
84
|
-
except Exception as exc:
|
|
85
|
-
handle_error(exc, "fetching Snapchat Ads ad accounts")
|
|
86
|
-
return []
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
"""TikTok Ads external API routes."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
|
|
7
|
-
import httpx
|
|
8
|
-
from fastapi import APIRouter, Depends, HTTPException
|
|
9
|
-
from interloper_db import Profile
|
|
10
|
-
from pydantic import BaseModel
|
|
11
|
-
|
|
12
|
-
from interloper_api.dependencies import require_viewer
|
|
13
|
-
from interloper_api.routes.external import handle_error
|
|
14
|
-
|
|
15
|
-
sub_router = APIRouter()
|
|
16
|
-
|
|
17
|
-
_BASE_URL = "https://business-api.tiktok.com/open_api/v1.3"
|
|
18
|
-
_ADVERTISERS_URL = f"{_BASE_URL}/oauth2/advertiser/get/"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class TiktokAdsConnectionRequest(BaseModel):
|
|
22
|
-
"""TikTok Ads connection credentials (matches TiktokAdsConnection fields)."""
|
|
23
|
-
|
|
24
|
-
access_token: str
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
async def _list_advertisers(
|
|
28
|
-
client: httpx.AsyncClient,
|
|
29
|
-
access_token: str,
|
|
30
|
-
app_id: str,
|
|
31
|
-
secret: str,
|
|
32
|
-
) -> list[dict[str, str]]:
|
|
33
|
-
"""List the advertisers the access token can access, sorted by name."""
|
|
34
|
-
resp = await client.get(
|
|
35
|
-
_ADVERTISERS_URL,
|
|
36
|
-
params={"app_id": app_id, "secret": secret},
|
|
37
|
-
headers={"Access-Token": access_token},
|
|
38
|
-
)
|
|
39
|
-
resp.raise_for_status()
|
|
40
|
-
body = resp.json()
|
|
41
|
-
if body.get("code") != 0:
|
|
42
|
-
raise HTTPException(status_code=502, detail=f"TikTok API error: {body.get('message')}")
|
|
43
|
-
|
|
44
|
-
advertisers = body.get("data", {}).get("list", [])
|
|
45
|
-
results = [
|
|
46
|
-
{"advertiser_id": a["advertiser_id"], "name": a.get("advertiser_name") or a["advertiser_id"]}
|
|
47
|
-
for a in advertisers
|
|
48
|
-
]
|
|
49
|
-
return sorted(results, key=lambda a: a["name"].lower())
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@sub_router.post("/tiktok-ads/advertisers")
|
|
53
|
-
async def tiktok_ads_advertisers(
|
|
54
|
-
body: TiktokAdsConnectionRequest,
|
|
55
|
-
_user: Profile = Depends(require_viewer),
|
|
56
|
-
) -> list[dict[str, str]]:
|
|
57
|
-
"""Fetch the TikTok advertisers accessible by the connection.
|
|
58
|
-
|
|
59
|
-
Listing advertisers needs the connector app's ``app_id`` / ``secret`` — read
|
|
60
|
-
from the provider-scoped environment (``TIKTOK_CLIENT_ID`` /
|
|
61
|
-
``TIKTOK_CLIENT_SECRET``) — alongside the connection's access token.
|
|
62
|
-
"""
|
|
63
|
-
try:
|
|
64
|
-
app_id = os.environ.get("TIKTOK_CLIENT_ID", "")
|
|
65
|
-
secret = os.environ.get("TIKTOK_CLIENT_SECRET", "")
|
|
66
|
-
async with httpx.AsyncClient(timeout=30) as client:
|
|
67
|
-
return await _list_advertisers(client, body.access_token, app_id, secret)
|
|
68
|
-
except Exception as exc:
|
|
69
|
-
handle_error(exc, "fetching TikTok Ads advertisers")
|
|
70
|
-
return []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|