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.
- interloper_api/__init__.py +3 -0
- interloper_api/app.py +100 -0
- interloper_api/dependencies.py +293 -0
- interloper_api/email.py +79 -0
- interloper_api/routes/__init__.py +0 -0
- interloper_api/routes/agent.py +229 -0
- interloper_api/routes/assets.py +304 -0
- interloper_api/routes/auth.py +241 -0
- interloper_api/routes/backfills.py +87 -0
- interloper_api/routes/catalog.py +46 -0
- interloper_api/routes/destinations.py +118 -0
- interloper_api/routes/external/__init__.py +48 -0
- interloper_api/routes/external/amazon_ads.py +82 -0
- interloper_api/routes/external/facebook_ads.py +55 -0
- interloper_api/routes/external/google_ads.py +104 -0
- interloper_api/routes/external/pinterest_ads.py +77 -0
- interloper_api/routes/external/snapchat_ads.py +86 -0
- interloper_api/routes/jobs.py +216 -0
- interloper_api/routes/oauth.py +345 -0
- interloper_api/routes/organisations.py +278 -0
- interloper_api/routes/resources.py +180 -0
- interloper_api/routes/runs.py +177 -0
- interloper_api/routes/sources.py +187 -0
- interloper_api/routes/ws.py +164 -0
- interloper_api-0.2.0.dist-info/METADATA +18 -0
- interloper_api-0.2.0.dist-info/RECORD +27 -0
- interloper_api-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Jobs API: CRUD for scheduled materialization jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
9
|
+
from interloper.errors import NotFoundError
|
|
10
|
+
from interloper_db import Profile, Store
|
|
11
|
+
from interloper_db.models import Job
|
|
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 JobCreateRequest(BaseModel):
|
|
20
|
+
"""Request body for creating or updating a job."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
cron: str
|
|
24
|
+
source_ids: list[UUID] | None = None
|
|
25
|
+
asset_ids: list[UUID] | None = None
|
|
26
|
+
tags: list[str] | None = None
|
|
27
|
+
enabled: bool = True
|
|
28
|
+
partitioned: bool = False
|
|
29
|
+
backfill_days: int | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class JobResponse(BaseModel):
|
|
33
|
+
"""Response body for a job."""
|
|
34
|
+
|
|
35
|
+
id: UUID
|
|
36
|
+
org_id: UUID
|
|
37
|
+
name: str
|
|
38
|
+
cron: str
|
|
39
|
+
tags: list[str]
|
|
40
|
+
enabled: bool
|
|
41
|
+
partitioned: bool
|
|
42
|
+
backfill_days: int | None
|
|
43
|
+
source_ids: list[UUID]
|
|
44
|
+
asset_ids: list[UUID]
|
|
45
|
+
last_run_at: str | None = None
|
|
46
|
+
next_run_at: str | None = None
|
|
47
|
+
created_at: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RunRequest(BaseModel):
|
|
51
|
+
"""Request body for queuing a single run."""
|
|
52
|
+
|
|
53
|
+
partition_date: dt.date | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BackfillRequest(BaseModel):
|
|
57
|
+
"""Request body for queuing a backfill."""
|
|
58
|
+
|
|
59
|
+
start_date: dt.date
|
|
60
|
+
end_date: dt.date
|
|
61
|
+
concurrency: int = 1
|
|
62
|
+
fail_fast: bool = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _job_to_response(job: Job) -> JobResponse:
|
|
66
|
+
"""Convert a DB Job to a JobResponse.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
job: The DB Job row with sources loaded.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The response model.
|
|
73
|
+
"""
|
|
74
|
+
return JobResponse(
|
|
75
|
+
id=job.id, # type: ignore[arg-type]
|
|
76
|
+
org_id=job.org_id,
|
|
77
|
+
name=job.name,
|
|
78
|
+
cron=job.cron,
|
|
79
|
+
tags=job.tags,
|
|
80
|
+
enabled=job.enabled,
|
|
81
|
+
partitioned=job.partitioned,
|
|
82
|
+
backfill_days=job.backfill_days,
|
|
83
|
+
source_ids=[s.id for s in job.sources], # type: ignore[misc]
|
|
84
|
+
asset_ids=[a.id for a in job.assets if a.id],
|
|
85
|
+
last_run_at=str(job.last_run_at) if job.last_run_at else None,
|
|
86
|
+
next_run_at=str(job.next_run_at) if job.next_run_at else None,
|
|
87
|
+
created_at=str(job.created_at) if job.created_at else None,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.get("/")
|
|
92
|
+
def list_jobs(
|
|
93
|
+
user: Profile = Depends(require_viewer),
|
|
94
|
+
org_id: UUID = Depends(get_org_id),
|
|
95
|
+
store: Store = Depends(get_store),
|
|
96
|
+
) -> list[JobResponse]:
|
|
97
|
+
"""List all jobs for the current organisation."""
|
|
98
|
+
jobs = store.list_jobs(org_id)
|
|
99
|
+
return [_job_to_response(j) for j in jobs]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.get("/{job_id}")
|
|
103
|
+
def get_job(
|
|
104
|
+
job_id: UUID,
|
|
105
|
+
user: Profile = Depends(require_viewer),
|
|
106
|
+
store: Store = Depends(get_store),
|
|
107
|
+
) -> JobResponse:
|
|
108
|
+
"""Get a single job by ID."""
|
|
109
|
+
try:
|
|
110
|
+
job = store.get_job(job_id)
|
|
111
|
+
except NotFoundError:
|
|
112
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
113
|
+
return _job_to_response(job)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@router.post("/")
|
|
117
|
+
def create_job(
|
|
118
|
+
body: JobCreateRequest,
|
|
119
|
+
user: Profile = Depends(require_editor),
|
|
120
|
+
org_id: UUID = Depends(get_org_id),
|
|
121
|
+
store: Store = Depends(get_store),
|
|
122
|
+
) -> JobResponse:
|
|
123
|
+
"""Create a new job."""
|
|
124
|
+
job = store.create_job(
|
|
125
|
+
org_id,
|
|
126
|
+
name=body.name,
|
|
127
|
+
cron=body.cron,
|
|
128
|
+
source_ids=body.source_ids,
|
|
129
|
+
asset_ids=body.asset_ids,
|
|
130
|
+
tags=body.tags,
|
|
131
|
+
enabled=body.enabled,
|
|
132
|
+
partitioned=body.partitioned,
|
|
133
|
+
backfill_days=body.backfill_days,
|
|
134
|
+
)
|
|
135
|
+
return _job_to_response(store.get_job(job.id)) # type: ignore[arg-type]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.put("/{job_id}")
|
|
139
|
+
def update_job(
|
|
140
|
+
job_id: UUID,
|
|
141
|
+
body: JobCreateRequest,
|
|
142
|
+
user: Profile = Depends(require_editor),
|
|
143
|
+
store: Store = Depends(get_store),
|
|
144
|
+
) -> JobResponse:
|
|
145
|
+
"""Update an existing job."""
|
|
146
|
+
try:
|
|
147
|
+
job = store.update_job(
|
|
148
|
+
job_id,
|
|
149
|
+
name=body.name,
|
|
150
|
+
cron=body.cron,
|
|
151
|
+
source_ids=body.source_ids,
|
|
152
|
+
asset_ids=body.asset_ids,
|
|
153
|
+
tags=body.tags,
|
|
154
|
+
enabled=body.enabled,
|
|
155
|
+
partitioned=body.partitioned,
|
|
156
|
+
backfill_days=body.backfill_days,
|
|
157
|
+
)
|
|
158
|
+
except NotFoundError:
|
|
159
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
160
|
+
return _job_to_response(store.get_job(job.id)) # type: ignore[arg-type]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@router.delete("/{job_id}")
|
|
164
|
+
def delete_job(
|
|
165
|
+
job_id: UUID,
|
|
166
|
+
user: Profile = Depends(require_editor),
|
|
167
|
+
store: Store = Depends(get_store),
|
|
168
|
+
) -> dict[str, str]:
|
|
169
|
+
"""Delete a job."""
|
|
170
|
+
store.delete_job(job_id)
|
|
171
|
+
return {"status": "deleted"}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@router.post("/{job_id}/run")
|
|
175
|
+
def queue_run(
|
|
176
|
+
job_id: UUID,
|
|
177
|
+
body: RunRequest | None = None,
|
|
178
|
+
user: Profile = Depends(require_editor),
|
|
179
|
+
store: Store = Depends(get_store),
|
|
180
|
+
) -> dict[str, str]:
|
|
181
|
+
"""Queue a single run for a job."""
|
|
182
|
+
try:
|
|
183
|
+
job = store.get_job(job_id)
|
|
184
|
+
except NotFoundError:
|
|
185
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
186
|
+
|
|
187
|
+
run = store.create_run(
|
|
188
|
+
job.org_id,
|
|
189
|
+
job_id=job_id,
|
|
190
|
+
partition_date=body.partition_date if body else None,
|
|
191
|
+
)
|
|
192
|
+
return {"status": "queued", "run_id": str(run.id)}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@router.post("/{job_id}/backfill")
|
|
196
|
+
def queue_backfill(
|
|
197
|
+
job_id: UUID,
|
|
198
|
+
body: BackfillRequest,
|
|
199
|
+
user: Profile = Depends(require_editor),
|
|
200
|
+
store: Store = Depends(get_store),
|
|
201
|
+
) -> dict[str, str]:
|
|
202
|
+
"""Queue a backfill for a job."""
|
|
203
|
+
try:
|
|
204
|
+
job = store.get_job(job_id)
|
|
205
|
+
except NotFoundError:
|
|
206
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
207
|
+
|
|
208
|
+
backfill = store.create_backfill(
|
|
209
|
+
job.org_id,
|
|
210
|
+
job_id=job_id,
|
|
211
|
+
start_date=body.start_date,
|
|
212
|
+
end_date=body.end_date,
|
|
213
|
+
concurrency=body.concurrency,
|
|
214
|
+
fail_fast=body.fail_fast,
|
|
215
|
+
)
|
|
216
|
+
return {"status": "queued", "backfill_id": str(backfill.id)}
|
|
@@ -0,0 +1,345 @@
|
|
|
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}")
|