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.
Files changed (34) hide show
  1. {interloper_api-0.22.0 → interloper_api-0.23.0}/PKG-INFO +1 -1
  2. {interloper_api-0.22.0 → interloper_api-0.23.0}/pyproject.toml +1 -1
  3. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/dependencies.py +6 -4
  4. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/catalog.py +10 -10
  5. interloper_api-0.23.0/src/interloper_api/routes/external/__init__.py +40 -0
  6. interloper_api-0.23.0/src/interloper_api/routes/external/resolve.py +88 -0
  7. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/resources.py +3 -2
  8. interloper_api-0.22.0/src/interloper_api/routes/external/__init__.py +0 -56
  9. interloper_api-0.22.0/src/interloper_api/routes/external/amazon_ads.py +0 -82
  10. interloper_api-0.22.0/src/interloper_api/routes/external/criteo.py +0 -68
  11. interloper_api-0.22.0/src/interloper_api/routes/external/facebook_ads.py +0 -55
  12. interloper_api-0.22.0/src/interloper_api/routes/external/google_ads.py +0 -104
  13. interloper_api-0.22.0/src/interloper_api/routes/external/google_cloud.py +0 -167
  14. interloper_api-0.22.0/src/interloper_api/routes/external/impact.py +0 -54
  15. interloper_api-0.22.0/src/interloper_api/routes/external/pinterest_ads.py +0 -77
  16. interloper_api-0.22.0/src/interloper_api/routes/external/snapchat_ads.py +0 -86
  17. interloper_api-0.22.0/src/interloper_api/routes/external/tiktok_ads.py +0 -70
  18. {interloper_api-0.22.0 → interloper_api-0.23.0}/README.md +0 -0
  19. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/__init__.py +0 -0
  20. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/app.py +0 -0
  21. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/email.py +0 -0
  22. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/__init__.py +0 -0
  23. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/admin.py +0 -0
  24. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/agent.py +0 -0
  25. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/assets.py +0 -0
  26. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/auth.py +0 -0
  27. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/backfills.py +0 -0
  28. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/destinations.py +0 -0
  29. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/jobs.py +0 -0
  30. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/oauth.py +0 -0
  31. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/organisations.py +0 -0
  32. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/runs.py +0 -0
  33. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/sources.py +0 -0
  34. {interloper_api-0.22.0 → interloper_api-0.23.0}/src/interloper_api/routes/ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.22.0
3
+ Version: 0.23.0
4
4
  Summary: Interloper FastAPI routes
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.22.0"
6
+ version = "0.23.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -63,18 +63,20 @@ def get_store() -> Store:
63
63
  return _store
64
64
 
65
65
 
66
- def get_catalog() -> dict[str, Any]:
67
- """Return the global catalog as a serialized dict.
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
- Mapping from component key to serialized definition.
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.dump()
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: dict[str, Any] = Depends(get_catalog)) -> dict[str, Any]:
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: dict[str, Any] = Depends(get_catalog)) -> dict[str, Any]:
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
- from fastapi import HTTPException
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 catalog[key]
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: dict[str, Any] = Depends(get_catalog)) -> dict[str, Any]:
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.get("kind") == kind}
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: dict[str, Any] = Depends(get_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["kind"] for defn in catalog.values() if defn.get("kind") and defn["kind"] not in _NON_RESOURCE_KINDS}
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 []