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,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}")