interloper-api 0.11.0__tar.gz → 0.14.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.11.0 → interloper_api-0.14.0}/PKG-INFO +2 -1
- {interloper_api-0.11.0 → interloper_api-0.14.0}/pyproject.toml +2 -1
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/__init__.py +2 -0
- interloper_api-0.14.0/src/interloper_api/routes/external/google_cloud.py +167 -0
- interloper_api-0.14.0/src/interloper_api/routes/oauth.py +184 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/runs.py +8 -4
- interloper_api-0.11.0/src/interloper_api/routes/oauth.py +0 -345
- {interloper_api-0.11.0 → interloper_api-0.14.0}/README.md +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/app.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/dependencies.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/email.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/admin.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/assets.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/auth.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/backfills.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/destinations.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/amazon_ads.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/facebook_ads.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/google_ads.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/pinterest_ads.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/snapchat_ads.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/jobs.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/organisations.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/resources.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/sources.py +0 -0
- {interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/ws.py +0 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: interloper-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.0
|
|
4
4
|
Summary: Interloper FastAPI routes
|
|
5
5
|
Author: Guillaume Onfroy
|
|
6
6
|
Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
|
|
7
7
|
Requires-Dist: interloper-core
|
|
8
8
|
Requires-Dist: interloper-db
|
|
9
9
|
Requires-Dist: fastapi>=0.115.0
|
|
10
|
+
Requires-Dist: google-auth>=2.0.0
|
|
10
11
|
Requires-Dist: httpx>=0.28.0
|
|
11
12
|
Requires-Dist: psycopg2-binary>=2.9.0
|
|
12
13
|
Requires-Dist: uvicorn[standard]>=0.41.0
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# ###############
|
|
4
4
|
[project]
|
|
5
5
|
name = "interloper-api"
|
|
6
|
-
version = "0.
|
|
6
|
+
version = "0.14.0"
|
|
7
7
|
description = "Interloper FastAPI routes"
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
|
|
@@ -12,6 +12,7 @@ dependencies = [
|
|
|
12
12
|
"interloper-core",
|
|
13
13
|
"interloper-db",
|
|
14
14
|
"fastapi>=0.115.0",
|
|
15
|
+
"google-auth>=2.0.0",
|
|
15
16
|
"httpx>=0.28.0",
|
|
16
17
|
"psycopg2-binary>=2.9.0",
|
|
17
18
|
"uvicorn[standard]>=0.41.0",
|
{interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/__init__.py
RENAMED
|
@@ -38,11 +38,13 @@ def handle_error(error: Exception, context: str) -> None:
|
|
|
38
38
|
from interloper_api.routes.external.amazon_ads import sub_router as amazon_ads_router # noqa: E402
|
|
39
39
|
from interloper_api.routes.external.facebook_ads import sub_router as facebook_ads_router # noqa: E402
|
|
40
40
|
from interloper_api.routes.external.google_ads import sub_router as google_ads_router # noqa: E402
|
|
41
|
+
from interloper_api.routes.external.google_cloud import sub_router as google_cloud_router # noqa: E402
|
|
41
42
|
from interloper_api.routes.external.pinterest_ads import sub_router as pinterest_ads_router # noqa: E402
|
|
42
43
|
from interloper_api.routes.external.snapchat_ads import sub_router as snapchat_ads_router # noqa: E402
|
|
43
44
|
|
|
44
45
|
router.include_router(amazon_ads_router)
|
|
45
46
|
router.include_router(facebook_ads_router)
|
|
46
47
|
router.include_router(google_ads_router)
|
|
48
|
+
router.include_router(google_cloud_router)
|
|
47
49
|
router.include_router(pinterest_ads_router)
|
|
48
50
|
router.include_router(snapchat_ads_router)
|
|
@@ -0,0 +1,167 @@
|
|
|
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
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""OAuth2 token exchange routes.
|
|
2
|
+
|
|
3
|
+
Providers come from the core registry (``interloper.oauth``): each
|
|
4
|
+
``OAuthProvider`` carries a declarative token-exchange spec (URL, method,
|
|
5
|
+
encoding, parameter names), so the exchange is performed generically —
|
|
6
|
+
adding a provider is an ``interloper.oauth_providers`` entry point, not a
|
|
7
|
+
new route.
|
|
8
|
+
|
|
9
|
+
The connector *app* credentials (``client_id`` / ``client_secret`` /
|
|
10
|
+
``redirect_uri``) are read from provider-scoped environment variables
|
|
11
|
+
(``<PROVIDER>_CLIENT_ID``, …) and injected into the token response so they
|
|
12
|
+
can be stored alongside the tokens.
|
|
13
|
+
|
|
14
|
+
The ``GET /providers`` endpoint returns metadata for all providers that
|
|
15
|
+
have credentials configured, so the frontend knows which "Sign in with X"
|
|
16
|
+
buttons to render.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from fastapi import APIRouter, HTTPException
|
|
28
|
+
from interloper.oauth import OAuthProvider, providers
|
|
29
|
+
from pydantic import BaseModel
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
router = APIRouter(prefix="/oauth", tags=["oauth"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# App credentials (environment)
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _ProviderConfig:
|
|
42
|
+
"""Connector app credentials resolved from environment variables."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, key: str, *, env_prefix: str | None = None) -> None:
|
|
45
|
+
prefix = (env_prefix or key).upper()
|
|
46
|
+
self.key = key
|
|
47
|
+
self.client_id = os.environ.get(f"{prefix}_CLIENT_ID", "")
|
|
48
|
+
self.client_secret = os.environ.get(f"{prefix}_CLIENT_SECRET", "")
|
|
49
|
+
self.redirect_uri = os.environ.get(f"{prefix}_REDIRECT_URI", "")
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def configured(self) -> bool:
|
|
53
|
+
return bool(self.client_id and self.client_secret and self.redirect_uri)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load_providers() -> dict[str, _ProviderConfig]:
|
|
57
|
+
return {key: _ProviderConfig(key) for key in providers()}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Generic token exchange
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _exchange(
|
|
66
|
+
client: httpx.AsyncClient,
|
|
67
|
+
spec: OAuthProvider,
|
|
68
|
+
cfg: _ProviderConfig,
|
|
69
|
+
code: str,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
"""Exchange an authorization code for tokens, driven by the provider spec.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
client: The HTTP client to use.
|
|
75
|
+
spec: The provider's token-exchange spec.
|
|
76
|
+
cfg: The connector app credentials.
|
|
77
|
+
code: The authorization code.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The token response, with ``client_id`` / ``client_secret`` injected
|
|
81
|
+
so they can be stored alongside the tokens.
|
|
82
|
+
"""
|
|
83
|
+
logical_values = {
|
|
84
|
+
"grant_type": "authorization_code",
|
|
85
|
+
"code": code,
|
|
86
|
+
"redirect_uri": cfg.redirect_uri,
|
|
87
|
+
"client_id": cfg.client_id,
|
|
88
|
+
"client_secret": cfg.client_secret,
|
|
89
|
+
}
|
|
90
|
+
params = {wire: logical_values[logical] for logical, wire in spec.token_params.items()}
|
|
91
|
+
|
|
92
|
+
headers: dict[str, str] = {}
|
|
93
|
+
if spec.token_basic_auth:
|
|
94
|
+
creds = base64.b64encode(f"{cfg.client_id}:{cfg.client_secret}".encode()).decode()
|
|
95
|
+
headers["Authorization"] = f"Basic {creds}"
|
|
96
|
+
|
|
97
|
+
if spec.token_method == "get":
|
|
98
|
+
resp = await client.get(spec.token_url, params=params, headers=headers)
|
|
99
|
+
elif spec.token_encoding == "form":
|
|
100
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
101
|
+
resp = await client.post(spec.token_url, data=params, headers=headers)
|
|
102
|
+
else:
|
|
103
|
+
resp = await client.post(spec.token_url, json=params, headers=headers)
|
|
104
|
+
resp.raise_for_status()
|
|
105
|
+
|
|
106
|
+
result = resp.json()
|
|
107
|
+
result["client_id"] = cfg.client_id
|
|
108
|
+
result["client_secret"] = cfg.client_secret
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Routes
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TokenExchangeRequest(BaseModel):
|
|
118
|
+
"""Request body for exchanging an authorization code for tokens."""
|
|
119
|
+
|
|
120
|
+
code: str
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ProviderInfo(BaseModel):
|
|
124
|
+
"""Public provider metadata (no secrets)."""
|
|
125
|
+
|
|
126
|
+
key: str
|
|
127
|
+
client_id: str
|
|
128
|
+
redirect_uri: str
|
|
129
|
+
auth_url: str = ""
|
|
130
|
+
label: str = ""
|
|
131
|
+
icon: str = ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@router.get("/providers")
|
|
135
|
+
def list_providers() -> list[ProviderInfo]:
|
|
136
|
+
"""Return metadata for all configured OAuth providers.
|
|
137
|
+
|
|
138
|
+
Only registered providers with ``CLIENT_ID``, ``CLIENT_SECRET``, and
|
|
139
|
+
``REDIRECT_URI`` environment variables set are included. Metadata
|
|
140
|
+
(auth_url, label, icon) comes from the provider registry.
|
|
141
|
+
"""
|
|
142
|
+
specs = providers()
|
|
143
|
+
return [
|
|
144
|
+
ProviderInfo(
|
|
145
|
+
key=cfg.key,
|
|
146
|
+
client_id=cfg.client_id,
|
|
147
|
+
redirect_uri=cfg.redirect_uri,
|
|
148
|
+
auth_url=specs[cfg.key].auth_url,
|
|
149
|
+
label=specs[cfg.key].label,
|
|
150
|
+
icon=specs[cfg.key].icon,
|
|
151
|
+
)
|
|
152
|
+
for cfg in _load_providers().values()
|
|
153
|
+
if cfg.configured
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@router.post("/{provider}")
|
|
158
|
+
async def exchange_token(provider: str, body: TokenExchangeRequest) -> dict[str, Any]:
|
|
159
|
+
"""Exchange an authorization code for tokens.
|
|
160
|
+
|
|
161
|
+
The response includes ``client_id`` and ``client_secret`` so they
|
|
162
|
+
can be stored alongside the tokens in the connection data.
|
|
163
|
+
"""
|
|
164
|
+
spec = providers().get(provider)
|
|
165
|
+
if spec is None:
|
|
166
|
+
raise HTTPException(status_code=400, detail=f"Unknown OAuth provider: {provider}")
|
|
167
|
+
|
|
168
|
+
cfg = _ProviderConfig(provider)
|
|
169
|
+
if not cfg.configured:
|
|
170
|
+
raise HTTPException(status_code=400, detail=f"OAuth provider {provider} is not configured")
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
logger.info("Exchanging auth code for provider %s", provider)
|
|
174
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
175
|
+
result = await _exchange(client, spec, cfg, body.code)
|
|
176
|
+
logger.info("Successfully exchanged auth code for provider %s", provider)
|
|
177
|
+
return result
|
|
178
|
+
except httpx.HTTPStatusError as exc:
|
|
179
|
+
detail = exc.response.text
|
|
180
|
+
logger.error("Token exchange failed for %s: %s %s", provider, exc.response.status_code, detail)
|
|
181
|
+
raise HTTPException(status_code=500, detail=f"Failed to exchange auth code: {detail}")
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
logger.error("Token exchange failed for %s: %s", provider, exc)
|
|
184
|
+
raise HTTPException(status_code=500, detail=f"Failed to exchange auth code: {exc}")
|
|
@@ -71,6 +71,7 @@ class EventResponse(BaseModel):
|
|
|
71
71
|
error: str | None
|
|
72
72
|
traceback: str | None
|
|
73
73
|
message: str | None
|
|
74
|
+
level: str | None
|
|
74
75
|
timestamp: str
|
|
75
76
|
|
|
76
77
|
|
|
@@ -119,6 +120,7 @@ def _event_to_response(event: Event) -> EventResponse:
|
|
|
119
120
|
error=event.error,
|
|
120
121
|
traceback=event.traceback,
|
|
121
122
|
message=event.message,
|
|
123
|
+
level=event.level,
|
|
122
124
|
timestamp=str(event.timestamp),
|
|
123
125
|
)
|
|
124
126
|
|
|
@@ -219,20 +221,22 @@ def list_run_events(
|
|
|
219
221
|
response: Response,
|
|
220
222
|
limit: int = 100,
|
|
221
223
|
offset: int = 0,
|
|
224
|
+
asset_id: UUID | None = None,
|
|
222
225
|
user: Profile = Depends(require_viewer),
|
|
223
226
|
store: Store = Depends(get_store),
|
|
224
227
|
) -> list[EventResponse]:
|
|
225
228
|
"""List events for a run, oldest first.
|
|
226
229
|
|
|
227
230
|
Events are ordered ``timestamp ASC`` and paged with ``limit``/``offset``.
|
|
228
|
-
|
|
229
|
-
|
|
231
|
+
``asset_id`` narrows the listing to one asset's events. The total number
|
|
232
|
+
of matching events (ignoring ``limit``/``offset``, honouring ``asset_id``)
|
|
233
|
+
is returned in the ``X-Total-Count`` response header so clients can page
|
|
230
234
|
through every event — including the terminal/outcome events
|
|
231
235
|
(``asset_completed``, ``asset_failed``, ``run_failed``, …) that sort last.
|
|
232
236
|
"""
|
|
233
237
|
limit = max(1, min(limit, MAX_EVENTS_PAGE_SIZE))
|
|
234
238
|
offset = max(0, offset)
|
|
235
|
-
total = store.count_events(run_id=run_id)
|
|
239
|
+
total = store.count_events(run_id=run_id, asset_id=asset_id)
|
|
236
240
|
response.headers["X-Total-Count"] = str(total)
|
|
237
|
-
events = store.list_events(run_id=run_id, limit=limit, offset=offset)
|
|
241
|
+
events = store.list_events(run_id=run_id, asset_id=asset_id, limit=limit, offset=offset)
|
|
238
242
|
return [_event_to_response(e) for e in events]
|
|
@@ -1,345 +0,0 @@
|
|
|
1
|
-
"""OAuth2 token exchange routes.
|
|
2
|
-
|
|
3
|
-
Each provider has an explicit exchange function with its own URL,
|
|
4
|
-
HTTP method, body encoding, and parameter names. The ``client_id``
|
|
5
|
-
and ``client_secret`` are read from environment variables and injected
|
|
6
|
-
into the token response so they can be stored alongside the tokens.
|
|
7
|
-
|
|
8
|
-
The ``GET /providers`` endpoint returns metadata for all providers
|
|
9
|
-
that have credentials configured, so the frontend knows which
|
|
10
|
-
"Sign in with X" buttons to render.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import base64
|
|
16
|
-
import logging
|
|
17
|
-
import os
|
|
18
|
-
from typing import Any
|
|
19
|
-
|
|
20
|
-
import httpx
|
|
21
|
-
from fastapi import APIRouter, Depends, HTTPException
|
|
22
|
-
from pydantic import BaseModel
|
|
23
|
-
|
|
24
|
-
from interloper_api.dependencies import get_catalog
|
|
25
|
-
|
|
26
|
-
logger = logging.getLogger(__name__)
|
|
27
|
-
|
|
28
|
-
router = APIRouter(prefix="/oauth", tags=["oauth"])
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
# ---------------------------------------------------------------------------
|
|
32
|
-
# Provider registry
|
|
33
|
-
# ---------------------------------------------------------------------------
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class _ProviderConfig:
|
|
37
|
-
"""Runtime provider config resolved from environment variables."""
|
|
38
|
-
|
|
39
|
-
def __init__(self, key: str, *, env_prefix: str | None = None) -> None:
|
|
40
|
-
prefix = (env_prefix or key).upper()
|
|
41
|
-
self.key = key
|
|
42
|
-
self.client_id = os.environ.get(f"{prefix}_CLIENT_ID", "")
|
|
43
|
-
self.client_secret = os.environ.get(f"{prefix}_CLIENT_SECRET", "")
|
|
44
|
-
self.redirect_uri = os.environ.get(f"{prefix}_REDIRECT_URI", "")
|
|
45
|
-
|
|
46
|
-
@property
|
|
47
|
-
def configured(self) -> bool:
|
|
48
|
-
return bool(self.client_id and self.client_secret and self.redirect_uri)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
_PROVIDER_KEYS = [
|
|
52
|
-
"amazon",
|
|
53
|
-
"criteo",
|
|
54
|
-
"facebook",
|
|
55
|
-
"google",
|
|
56
|
-
"linkedin",
|
|
57
|
-
"microsoft",
|
|
58
|
-
"pinterest",
|
|
59
|
-
"snapchat",
|
|
60
|
-
"tiktok",
|
|
61
|
-
]
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _load_providers() -> dict[str, _ProviderConfig]:
|
|
65
|
-
return {k: _ProviderConfig(k) for k in _PROVIDER_KEYS}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# ---------------------------------------------------------------------------
|
|
69
|
-
# Token exchange functions (one per provider)
|
|
70
|
-
# ---------------------------------------------------------------------------
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
async def _amazon(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
|
|
74
|
-
resp = await client.post(
|
|
75
|
-
"https://api.amazon.com/auth/o2/token",
|
|
76
|
-
json={
|
|
77
|
-
"grant_type": "authorization_code",
|
|
78
|
-
"code": code,
|
|
79
|
-
"redirect_uri": cfg.redirect_uri,
|
|
80
|
-
"client_id": cfg.client_id,
|
|
81
|
-
"client_secret": cfg.client_secret,
|
|
82
|
-
},
|
|
83
|
-
)
|
|
84
|
-
resp.raise_for_status()
|
|
85
|
-
result = resp.json()
|
|
86
|
-
result["client_id"] = cfg.client_id
|
|
87
|
-
result["client_secret"] = cfg.client_secret
|
|
88
|
-
return result
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
async def _criteo(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
|
|
92
|
-
resp = await client.post(
|
|
93
|
-
"https://api.criteo.com/oauth2/token",
|
|
94
|
-
json={
|
|
95
|
-
"grant_type": "authorization_code",
|
|
96
|
-
"code": code,
|
|
97
|
-
"redirect_uri": cfg.redirect_uri,
|
|
98
|
-
"client_id": cfg.client_id,
|
|
99
|
-
"client_secret": cfg.client_secret,
|
|
100
|
-
},
|
|
101
|
-
)
|
|
102
|
-
resp.raise_for_status()
|
|
103
|
-
result = resp.json()
|
|
104
|
-
result["client_id"] = cfg.client_id
|
|
105
|
-
result["client_secret"] = cfg.client_secret
|
|
106
|
-
return result
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
async def _facebook(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
|
|
110
|
-
resp = await client.get(
|
|
111
|
-
"https://graph.facebook.com/v19.0/oauth/access_token",
|
|
112
|
-
params={
|
|
113
|
-
"code": code,
|
|
114
|
-
"redirect_uri": cfg.redirect_uri,
|
|
115
|
-
"client_id": cfg.client_id,
|
|
116
|
-
"client_secret": cfg.client_secret,
|
|
117
|
-
},
|
|
118
|
-
)
|
|
119
|
-
resp.raise_for_status()
|
|
120
|
-
result = resp.json()
|
|
121
|
-
result["client_id"] = cfg.client_id
|
|
122
|
-
result["client_secret"] = cfg.client_secret
|
|
123
|
-
return result
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
async def _google(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
|
|
127
|
-
resp = await client.post(
|
|
128
|
-
"https://oauth2.googleapis.com/token",
|
|
129
|
-
json={
|
|
130
|
-
"grant_type": "authorization_code",
|
|
131
|
-
"code": code,
|
|
132
|
-
"redirect_uri": cfg.redirect_uri,
|
|
133
|
-
"client_id": cfg.client_id,
|
|
134
|
-
"client_secret": cfg.client_secret,
|
|
135
|
-
},
|
|
136
|
-
)
|
|
137
|
-
resp.raise_for_status()
|
|
138
|
-
result = resp.json()
|
|
139
|
-
result["client_id"] = cfg.client_id
|
|
140
|
-
result["client_secret"] = cfg.client_secret
|
|
141
|
-
return result
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
async def _linkedin(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
|
|
145
|
-
resp = await client.post(
|
|
146
|
-
"https://www.linkedin.com/oauth/v2/accessToken",
|
|
147
|
-
data={
|
|
148
|
-
"grant_type": "authorization_code",
|
|
149
|
-
"code": code,
|
|
150
|
-
"redirect_uri": cfg.redirect_uri,
|
|
151
|
-
"client_id": cfg.client_id,
|
|
152
|
-
"client_secret": cfg.client_secret,
|
|
153
|
-
},
|
|
154
|
-
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
155
|
-
)
|
|
156
|
-
resp.raise_for_status()
|
|
157
|
-
result = resp.json()
|
|
158
|
-
result["client_id"] = cfg.client_id
|
|
159
|
-
result["client_secret"] = cfg.client_secret
|
|
160
|
-
return result
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
async def _microsoft(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
|
|
164
|
-
resp = await client.post(
|
|
165
|
-
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
|
166
|
-
data={
|
|
167
|
-
"grant_type": "authorization_code",
|
|
168
|
-
"code": code,
|
|
169
|
-
"redirect_uri": cfg.redirect_uri,
|
|
170
|
-
"client_id": cfg.client_id,
|
|
171
|
-
"client_secret": cfg.client_secret,
|
|
172
|
-
},
|
|
173
|
-
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
174
|
-
)
|
|
175
|
-
resp.raise_for_status()
|
|
176
|
-
result = resp.json()
|
|
177
|
-
result["client_id"] = cfg.client_id
|
|
178
|
-
result["client_secret"] = cfg.client_secret
|
|
179
|
-
return result
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
async def _pinterest(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
|
|
183
|
-
creds = base64.b64encode(f"{cfg.client_id}:{cfg.client_secret}".encode()).decode()
|
|
184
|
-
resp = await client.post(
|
|
185
|
-
"https://api.pinterest.com/v5/oauth/token",
|
|
186
|
-
data={
|
|
187
|
-
"grant_type": "authorization_code",
|
|
188
|
-
"code": code,
|
|
189
|
-
"redirect_uri": cfg.redirect_uri,
|
|
190
|
-
"client_id": cfg.client_id,
|
|
191
|
-
"client_secret": cfg.client_secret,
|
|
192
|
-
},
|
|
193
|
-
headers={
|
|
194
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
195
|
-
"Authorization": f"Basic {creds}",
|
|
196
|
-
},
|
|
197
|
-
)
|
|
198
|
-
resp.raise_for_status()
|
|
199
|
-
result = resp.json()
|
|
200
|
-
result["client_id"] = cfg.client_id
|
|
201
|
-
result["client_secret"] = cfg.client_secret
|
|
202
|
-
return result
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
async def _snapchat(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
|
|
206
|
-
resp = await client.post(
|
|
207
|
-
"https://accounts.snapchat.com/login/oauth2/access_token",
|
|
208
|
-
data={
|
|
209
|
-
"grant_type": "authorization_code",
|
|
210
|
-
"code": code,
|
|
211
|
-
"redirect_uri": cfg.redirect_uri,
|
|
212
|
-
"client_id": cfg.client_id,
|
|
213
|
-
"client_secret": cfg.client_secret,
|
|
214
|
-
},
|
|
215
|
-
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
216
|
-
)
|
|
217
|
-
resp.raise_for_status()
|
|
218
|
-
result = resp.json()
|
|
219
|
-
result["client_id"] = cfg.client_id
|
|
220
|
-
result["client_secret"] = cfg.client_secret
|
|
221
|
-
return result
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
async def _tiktok(client: httpx.AsyncClient, cfg: _ProviderConfig, code: str) -> dict[str, Any]:
|
|
225
|
-
resp = await client.post(
|
|
226
|
-
"https://business-api.tiktok.com/open_api/v1.3/oauth2/access_token",
|
|
227
|
-
json={
|
|
228
|
-
"auth_code": code,
|
|
229
|
-
"app_id": cfg.client_id,
|
|
230
|
-
"secret": cfg.client_secret,
|
|
231
|
-
},
|
|
232
|
-
)
|
|
233
|
-
resp.raise_for_status()
|
|
234
|
-
result = resp.json()
|
|
235
|
-
result["client_id"] = cfg.client_id
|
|
236
|
-
result["client_secret"] = cfg.client_secret
|
|
237
|
-
return result
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
_EXCHANGE_FNS: dict[str, Any] = {
|
|
241
|
-
"amazon": _amazon,
|
|
242
|
-
"criteo": _criteo,
|
|
243
|
-
"facebook": _facebook,
|
|
244
|
-
"google": _google,
|
|
245
|
-
"linkedin": _linkedin,
|
|
246
|
-
"microsoft": _microsoft,
|
|
247
|
-
"pinterest": _pinterest,
|
|
248
|
-
"snapchat": _snapchat,
|
|
249
|
-
"tiktok": _tiktok,
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
# ---------------------------------------------------------------------------
|
|
254
|
-
# Routes
|
|
255
|
-
# ---------------------------------------------------------------------------
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
class TokenExchangeRequest(BaseModel):
|
|
259
|
-
"""Request body for exchanging an authorization code for tokens."""
|
|
260
|
-
|
|
261
|
-
code: str
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
class ProviderInfo(BaseModel):
|
|
265
|
-
"""Public provider metadata (no secrets)."""
|
|
266
|
-
|
|
267
|
-
key: str
|
|
268
|
-
client_id: str
|
|
269
|
-
redirect_uri: str
|
|
270
|
-
auth_url: str = ""
|
|
271
|
-
label: str = ""
|
|
272
|
-
icon: str = ""
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
def _extract_oauth_metadata(catalog: dict[str, Any]) -> dict[str, dict[str, str]]:
|
|
276
|
-
"""Extract OAuth metadata (auth_url, label, icon) from catalog definitions.
|
|
277
|
-
|
|
278
|
-
Scans all resource definitions for ``x-oauth`` config schema extensions.
|
|
279
|
-
Returns a dict keyed by provider key.
|
|
280
|
-
"""
|
|
281
|
-
metadata: dict[str, dict[str, str]] = {}
|
|
282
|
-
for defn in catalog.values():
|
|
283
|
-
oauth_ext = defn.get("config_schema", {}).get("x-oauth")
|
|
284
|
-
if oauth_ext and isinstance(oauth_ext, dict):
|
|
285
|
-
provider = oauth_ext.get("provider", "")
|
|
286
|
-
if provider and provider not in metadata:
|
|
287
|
-
metadata[provider] = {
|
|
288
|
-
"auth_url": oauth_ext.get("auth_url", ""),
|
|
289
|
-
"label": oauth_ext.get("label", ""),
|
|
290
|
-
"icon": oauth_ext.get("icon", ""),
|
|
291
|
-
}
|
|
292
|
-
return metadata
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
@router.get("/providers")
|
|
296
|
-
def list_providers(catalog: dict[str, Any] = Depends(get_catalog)) -> list[ProviderInfo]:
|
|
297
|
-
"""Return metadata for all configured OAuth providers.
|
|
298
|
-
|
|
299
|
-
Only providers with ``CLIENT_ID``, ``CLIENT_SECRET``, and
|
|
300
|
-
``REDIRECT_URI`` environment variables set are included.
|
|
301
|
-
Metadata (auth_url, label, icon) is extracted from the catalog.
|
|
302
|
-
"""
|
|
303
|
-
providers = _load_providers()
|
|
304
|
-
oauth_meta = _extract_oauth_metadata(catalog)
|
|
305
|
-
return [
|
|
306
|
-
ProviderInfo(
|
|
307
|
-
key=cfg.key,
|
|
308
|
-
client_id=cfg.client_id,
|
|
309
|
-
redirect_uri=cfg.redirect_uri,
|
|
310
|
-
**(oauth_meta.get(cfg.key, {})),
|
|
311
|
-
)
|
|
312
|
-
for cfg in providers.values()
|
|
313
|
-
if cfg.configured
|
|
314
|
-
]
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
@router.post("/{provider}")
|
|
318
|
-
async def exchange_token(provider: str, body: TokenExchangeRequest) -> dict[str, Any]:
|
|
319
|
-
"""Exchange an authorization code for tokens.
|
|
320
|
-
|
|
321
|
-
The response includes ``client_id`` and ``client_secret`` so they
|
|
322
|
-
can be stored alongside the tokens in the connection data.
|
|
323
|
-
"""
|
|
324
|
-
exchange = _EXCHANGE_FNS.get(provider)
|
|
325
|
-
if exchange is None:
|
|
326
|
-
raise HTTPException(status_code=400, detail=f"Unknown OAuth provider: {provider}")
|
|
327
|
-
|
|
328
|
-
providers = _load_providers()
|
|
329
|
-
cfg = providers.get(provider)
|
|
330
|
-
if cfg is None or not cfg.configured:
|
|
331
|
-
raise HTTPException(status_code=400, detail=f"OAuth provider {provider} is not configured")
|
|
332
|
-
|
|
333
|
-
try:
|
|
334
|
-
logger.info("Exchanging auth code for provider %s", provider)
|
|
335
|
-
async with httpx.AsyncClient(timeout=30) as client:
|
|
336
|
-
result = await exchange(client, cfg, body.code)
|
|
337
|
-
logger.info("Successfully exchanged auth code for provider %s", provider)
|
|
338
|
-
return result
|
|
339
|
-
except httpx.HTTPStatusError as exc:
|
|
340
|
-
detail = exc.response.text
|
|
341
|
-
logger.error("Token exchange failed for %s: %s %s", provider, exc.response.status_code, detail)
|
|
342
|
-
raise HTTPException(status_code=500, detail=f"Failed to exchange auth code: {detail}")
|
|
343
|
-
except Exception as exc:
|
|
344
|
-
logger.error("Token exchange failed for %s: %s", provider, exc)
|
|
345
|
-
raise HTTPException(status_code=500, detail=f"Failed to exchange auth code: {exc}")
|
|
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
|
{interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/amazon_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/facebook_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/google_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/pinterest_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.11.0 → interloper_api-0.14.0}/src/interloper_api/routes/external/snapchat_ads.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|