interloper-api 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,118 @@
1
+ """Destinations API: CRUD for standalone destination instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from uuid import UUID
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException
9
+ from interloper.errors import NotFoundError
10
+ from interloper_db import Store
11
+ from interloper_db.models import Destination, DestinationResource
12
+ from pydantic import BaseModel
13
+
14
+ from interloper_api.dependencies import get_org_id, get_store, require_editor, require_viewer
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ class DestinationCreateRequest(BaseModel):
20
+ """Request body for creating/updating a destination."""
21
+
22
+ key: str
23
+ name: str | None = None
24
+ config: dict[str, Any] | None = None
25
+ resources: dict[str, str] | None = None
26
+
27
+
28
+ class DestinationResponse(BaseModel):
29
+ """Response body for a destination."""
30
+
31
+ id: UUID
32
+ key: str
33
+ name: str | None = None
34
+ config: dict[str, Any] | None = None
35
+ resources: dict[str, str] = {}
36
+ created_at: str | None = None
37
+
38
+
39
+ def _resource_map(dest: Destination) -> dict[str, str]:
40
+ """Build {slot_key: resource_id} from junction rows."""
41
+ from interloper_db.engine import get_engine
42
+ from sqlmodel import Session as _S
43
+ from sqlmodel import select as _sel
44
+
45
+ with _S(get_engine()) as s:
46
+ rows = s.exec(_sel(DestinationResource).where(DestinationResource.destination_id == dest.id)).all()
47
+ return {r.key: str(r.resource_id) for r in rows}
48
+
49
+
50
+ def _to_response(dest: Destination) -> DestinationResponse:
51
+ return DestinationResponse(
52
+ id=dest.id, # type: ignore[arg-type]
53
+ key=dest.key,
54
+ name=dest.name,
55
+ config=dest.config,
56
+ resources=_resource_map(dest),
57
+ created_at=str(dest.created_at) if dest.created_at else None,
58
+ )
59
+
60
+
61
+ @router.get("/")
62
+ def list_destinations(
63
+ user: object = Depends(require_viewer),
64
+ org_id: UUID = Depends(get_org_id),
65
+ store: Store = Depends(get_store),
66
+ ) -> list[DestinationResponse]:
67
+ """List all destinations for the current organisation."""
68
+ destinations = store.list_destinations(org_id)
69
+ return [_to_response(d) for d in destinations]
70
+
71
+
72
+ @router.post("/")
73
+ def create_destination(
74
+ body: DestinationCreateRequest,
75
+ user: object = Depends(require_editor),
76
+ org_id: UUID = Depends(get_org_id),
77
+ store: Store = Depends(get_store),
78
+ ) -> DestinationResponse:
79
+ """Create a new destination."""
80
+ dest = store.create_destination(
81
+ org_id,
82
+ key=body.key,
83
+ name=body.name,
84
+ config=body.config,
85
+ resources=body.resources,
86
+ )
87
+ return _to_response(dest)
88
+
89
+
90
+ @router.put("/{destination_id}")
91
+ def update_destination(
92
+ destination_id: UUID,
93
+ body: DestinationCreateRequest,
94
+ user: object = Depends(require_editor),
95
+ store: Store = Depends(get_store),
96
+ ) -> DestinationResponse:
97
+ """Update a destination."""
98
+ try:
99
+ dest = store.update_destination(
100
+ destination_id,
101
+ name=body.name,
102
+ config=body.config,
103
+ resources=body.resources,
104
+ )
105
+ except NotFoundError as e:
106
+ raise HTTPException(status_code=404, detail=str(e))
107
+ return _to_response(dest)
108
+
109
+
110
+ @router.delete("/{destination_id}")
111
+ def delete_destination(
112
+ destination_id: UUID,
113
+ user: object = Depends(require_editor),
114
+ store: Store = Depends(get_store),
115
+ ) -> dict[str, str]:
116
+ """Delete a destination."""
117
+ store.delete_destination(destination_id)
118
+ return {"status": "deleted"}
@@ -0,0 +1,48 @@
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.facebook_ads import sub_router as facebook_ads_router # noqa: E402
40
+ from interloper_api.routes.external.google_ads import sub_router as google_ads_router # noqa: E402
41
+ from interloper_api.routes.external.pinterest_ads import sub_router as pinterest_ads_router # noqa: E402
42
+ from interloper_api.routes.external.snapchat_ads import sub_router as snapchat_ads_router # noqa: E402
43
+
44
+ router.include_router(amazon_ads_router)
45
+ router.include_router(facebook_ads_router)
46
+ router.include_router(google_ads_router)
47
+ router.include_router(pinterest_ads_router)
48
+ router.include_router(snapchat_ads_router)
@@ -0,0 +1,82 @@
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
@@ -0,0 +1,55 @@
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 []
@@ -0,0 +1,104 @@
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"] # type: ignore[no-any-return]
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 []
@@ -0,0 +1,77 @@
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 []
@@ -0,0 +1,86 @@
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 []