interloper-api 0.30.1__tar.gz → 0.32.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {interloper_api-0.30.1 → interloper_api-0.32.0}/PKG-INFO +1 -1
  2. {interloper_api-0.30.1 → interloper_api-0.32.0}/pyproject.toml +1 -1
  3. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/app.py +8 -11
  4. interloper_api-0.32.0/src/interloper_api/components.py +64 -0
  5. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/dependencies.py +43 -2
  6. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/routes/admin.py +31 -8
  7. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/routes/agent.py +3 -9
  8. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/routes/auth.py +5 -0
  9. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/routes/backfills.py +41 -3
  10. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/routes/catalog.py +19 -0
  11. interloper_api-0.32.0/src/interloper_api/routes/components.py +368 -0
  12. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/routes/oauth.py +7 -13
  13. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/routes/organisations.py +7 -4
  14. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/routes/runs.py +43 -14
  15. interloper_api-0.30.1/src/interloper_api/routes/assets.py +0 -347
  16. interloper_api-0.30.1/src/interloper_api/routes/destinations.py +0 -141
  17. interloper_api-0.30.1/src/interloper_api/routes/jobs.py +0 -240
  18. interloper_api-0.30.1/src/interloper_api/routes/resources.py +0 -218
  19. interloper_api-0.30.1/src/interloper_api/routes/sources.py +0 -222
  20. {interloper_api-0.30.1 → interloper_api-0.32.0}/README.md +0 -0
  21. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/__init__.py +0 -0
  22. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/email.py +0 -0
  23. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/routes/__init__.py +0 -0
  24. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/routes/external/__init__.py +0 -0
  25. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/routes/external/resolve.py +0 -0
  26. {interloper_api-0.30.1 → interloper_api-0.32.0}/src/interloper_api/routes/ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.30.1
3
+ Version: 0.32.0
4
4
  Summary: Interloper FastAPI routes
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.30.1"
6
+ version = "0.32.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -9,23 +9,19 @@ from fastapi import APIRouter, FastAPI, Request
9
9
  from fastapi.middleware.cors import CORSMiddleware
10
10
  from fastapi.responses import JSONResponse
11
11
  from interloper.catalog.base import Catalog
12
- from interloper.errors import ComponentDriftError
12
+ from interloper.errors import ComponentDriftError, NotFoundError
13
13
  from interloper_db import Store
14
14
 
15
15
  from interloper_api.dependencies import set_auth_config, set_catalog, set_features, set_smtp_config, set_store
16
16
  from interloper_api.routes import (
17
17
  admin,
18
- assets,
19
18
  auth,
20
19
  backfills,
21
- destinations,
20
+ components,
22
21
  external,
23
- jobs,
24
22
  oauth,
25
23
  organisations,
26
- resources,
27
24
  runs,
28
- sources,
29
25
  ws,
30
26
  )
31
27
  from interloper_api.routes import catalog as catalog_routes
@@ -60,6 +56,11 @@ def create_app(
60
56
  """
61
57
  app = FastAPI(title="Interloper API", lifespan=ws.realtime_lifespan, **kwargs)
62
58
 
59
+ @app.exception_handler(NotFoundError)
60
+ async def _not_found_handler(_request: Request, exc: NotFoundError) -> JSONResponse:
61
+ """Store mutations raise NotFoundError on missing targets — a plain 404."""
62
+ return JSONResponse(status_code=404, content={"detail": str(exc)})
63
+
63
64
  @app.exception_handler(ComponentDriftError)
64
65
  async def _component_drift_handler(_request: Request, exc: ComponentDriftError) -> JSONResponse:
65
66
  """A stored component whose catalog key has drifted is a conflict, not a 500.
@@ -92,13 +93,9 @@ def create_app(
92
93
  api.include_router(organisations.router, tags=["organisations"])
93
94
  api.include_router(admin.router, tags=["admin"])
94
95
  api.include_router(catalog_routes.router, prefix="/catalog", tags=["catalog"])
95
- api.include_router(resources.router, prefix="/resources", tags=["resources"])
96
- api.include_router(sources.router, prefix="/sources", tags=["sources"])
97
- api.include_router(destinations.router, prefix="/destinations", tags=["destinations"])
98
- api.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
96
+ api.include_router(components.router, prefix="/components", tags=["components"])
99
97
  api.include_router(runs.router, prefix="/runs", tags=["runs"])
100
98
  api.include_router(backfills.router, prefix="/backfills", tags=["backfills"])
101
- api.include_router(assets.router, prefix="/assets", tags=["assets"])
102
99
  api.include_router(oauth.router, tags=["oauth"])
103
100
  api.include_router(external.router, prefix="/external", tags=["external"])
104
101
  api.include_router(ws.router, tags=["ws"])
@@ -0,0 +1,64 @@
1
+ """Shared helpers for reading component rows into API responses.
2
+
3
+ Store methods return ``Component`` rows with children and relations eager-loaded;
4
+ these helpers extract the shapes the response models need without touching
5
+ the database again.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from typing import Any
12
+ from uuid import UUID
13
+
14
+ from interloper_db import Component
15
+ from pydantic import BaseModel
16
+
17
+
18
+ class DestinationResponse(BaseModel):
19
+ """Response body for a destination, standalone or nested."""
20
+
21
+ id: UUID
22
+ key: str
23
+ name: str | None = None
24
+ config: dict[str, Any] | None = None
25
+ resources: dict[str, str] = {}
26
+ created_at: str | None = None
27
+
28
+
29
+ def destination_response(dest: Component) -> DestinationResponse:
30
+ """Convert a destination component row to its response model."""
31
+ return DestinationResponse(
32
+ id=dest.id,
33
+ key=dest.key,
34
+ name=dest.name,
35
+ config=dest.config,
36
+ resources=resource_map(dest),
37
+ created_at=timestamp(dest.created_at),
38
+ )
39
+
40
+
41
+ def timestamp(value: datetime | None) -> str | None:
42
+ """Render an optional timestamp the way every response does."""
43
+ return str(value) if value else None
44
+
45
+
46
+ def resource_map(component: Component) -> dict[str, str]:
47
+ """Build a ``{slot: resource_id}`` map from the component's resource relations."""
48
+ return {rel.slot: str(rel.dst_id) for rel in component.out_relations if rel.type == "resource"}
49
+
50
+
51
+ def destination_rows(component: Component) -> list[Component]:
52
+ """The destination components bound to this component, eager-loaded."""
53
+ return [rel.dst for rel in component.out_relations if rel.type == "destination"]
54
+
55
+
56
+ def materializable(component: Component) -> bool:
57
+ """An asset's materializable toggle (defaults to true when unset)."""
58
+ return bool((component.config or {}).get("materializable", True))
59
+
60
+
61
+ def user_config(component: Component) -> dict[str, Any] | None:
62
+ """The user-facing config, without the materializable toggle."""
63
+ config = {key: value for key, value in (component.config or {}).items() if key != "materializable"}
64
+ return config or None
@@ -2,13 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Callable
5
6
  from typing import Any
6
7
  from uuid import UUID
7
8
 
8
9
  from fastapi import Cookie, Depends, HTTPException
9
10
  from interloper.catalog.base import Catalog
11
+ from interloper.errors import NotFoundError
10
12
  from interloper_db import Organisation, Profile, Store
11
- from interloper_db.models import Session as SessionModel
13
+ from interloper_db.models import AuthSession
12
14
 
13
15
  _store: Store | None = None
14
16
  _catalog: Catalog | None = None
@@ -165,7 +167,7 @@ def get_current_user(
165
167
  def get_session_context(
166
168
  store: Store = Depends(get_store),
167
169
  session_token: str | None = Cookie(default=None),
168
- ) -> tuple[Profile, SessionModel]:
170
+ ) -> tuple[Profile, AuthSession]:
169
171
  """Resolve user and session from the cookie.
170
172
 
171
173
  Args:
@@ -273,6 +275,45 @@ def authorize_org_member(
273
275
  raise HTTPException(status_code=403, detail=f"Requires {minimum} role or higher")
274
276
 
275
277
 
278
+ def load_authorized(
279
+ fetch: Callable[[UUID], Any],
280
+ entity_id: UUID,
281
+ user: Profile,
282
+ store: Store,
283
+ *,
284
+ label: str,
285
+ minimum: str = "viewer",
286
+ ) -> Any:
287
+ """Fetch an org-owned entity and authorize the user by membership in its org.
288
+
289
+ The one ID-addressed authorization pattern shared by every entity route:
290
+ a missing entity and a non-member get the same 404 (IDs don't act as an
291
+ existence oracle); a member with an insufficient role gets a 403.
292
+
293
+ Args:
294
+ fetch: Store getter taking the entity id (e.g. ``store.get_asset``).
295
+ entity_id: The entity UUID.
296
+ user: The authenticated user.
297
+ store: The Store instance.
298
+ label: Entity label for the 404 detail (e.g. ``"Asset"``).
299
+ minimum: Minimum role required in the owning organisation.
300
+
301
+ Returns:
302
+ Whatever *fetch* returned.
303
+
304
+ Raises:
305
+ HTTPException: 404 if missing or the user is not a member of the
306
+ owning org, 403 if the role is insufficient.
307
+ """
308
+ detail = f"{label} {entity_id} not found"
309
+ try:
310
+ entity = fetch(entity_id)
311
+ except NotFoundError:
312
+ raise HTTPException(status_code=404, detail=detail)
313
+ authorize_org_member(user, entity.org_id, store, minimum=minimum, detail=detail)
314
+ return entity
315
+
316
+
276
317
  def _check_role(
277
318
  minimum: str,
278
319
  user: Profile,
@@ -67,6 +67,12 @@ class UpdateRoleRequest(BaseModel):
67
67
  role: str
68
68
 
69
69
 
70
+ class JoinOrganisationRequest(BaseModel):
71
+ """Request body for a super-admin joining an organisation."""
72
+
73
+ role: str = "admin"
74
+
75
+
70
76
  class InviteRequest(BaseModel):
71
77
  """Request body for inviting a user."""
72
78
 
@@ -113,6 +119,7 @@ def _send_invitation_email(
113
119
 
114
120
  smtp_config = get_smtp_config()
115
121
  if not smtp_config or not smtp_config.enabled:
122
+ logger.warning("SMTP not configured; invitation email to %s not sent", invitation.email)
116
123
  return
117
124
 
118
125
  token = invitation.token
@@ -177,8 +184,6 @@ def update_organisation(
177
184
  ) -> AdminOrganisationResponse:
178
185
  """Rename an organisation."""
179
186
  org = store.update_organisation(org_id, body.name)
180
- if not org:
181
- raise HTTPException(status_code=404, detail="Organisation not found")
182
187
  members = store.list_org_members(org_id)
183
188
  return AdminOrganisationResponse(
184
189
  id=org.id,
@@ -212,6 +217,27 @@ def list_members(
212
217
  ]
213
218
 
214
219
 
220
+ @router.post("/organisations/{org_id}/members", status_code=201)
221
+ def join_organisation(
222
+ org_id: UUID,
223
+ body: JoinOrganisationRequest,
224
+ user: Profile = Depends(require_super_admin),
225
+ store: Store = Depends(get_store),
226
+ ) -> MemberResponse:
227
+ """Add the calling super-admin to any organisation — no invitation needed."""
228
+ _require_org(store, org_id)
229
+ _validate_role(body.role)
230
+ if not store.add_org_member(org_id, user.id, body.role):
231
+ raise HTTPException(status_code=409, detail="Already a member of this organisation")
232
+ return MemberResponse(
233
+ id=user.id,
234
+ email=user.email,
235
+ name=user.name,
236
+ avatar_url=user.avatar_url,
237
+ role=body.role,
238
+ )
239
+
240
+
215
241
  @router.patch("/organisations/{org_id}/members/{user_id}")
216
242
  def update_member_role(
217
243
  org_id: UUID,
@@ -222,8 +248,7 @@ def update_member_role(
222
248
  ) -> dict[str, str]:
223
249
  """Change a member's role in any organisation."""
224
250
  _validate_role(body.role)
225
- if not store.update_member_role(org_id, user_id, body.role):
226
- raise HTTPException(status_code=404, detail="Member not found")
251
+ store.update_member_role(org_id, user_id, body.role)
227
252
  return {"status": "ok"}
228
253
 
229
254
 
@@ -235,8 +260,7 @@ def remove_member(
235
260
  store: Store = Depends(get_store),
236
261
  ) -> dict[str, str]:
237
262
  """Remove a member from any organisation."""
238
- if not store.remove_org_member(org_id, user_id):
239
- raise HTTPException(status_code=404, detail="Member not found")
263
+ store.remove_org_member(org_id, user_id)
240
264
  return {"status": "ok"}
241
265
 
242
266
 
@@ -301,6 +325,5 @@ def cancel_invitation(
301
325
  store: Store = Depends(get_store),
302
326
  ) -> dict[str, str]:
303
327
  """Cancel a pending invitation in any organisation."""
304
- if not store.delete_invitation(invitation_id):
305
- raise HTTPException(status_code=404, detail="Invitation not found")
328
+ store.delete_invitation(invitation_id)
306
329
  return {"status": "ok"}
@@ -33,9 +33,7 @@ router = APIRouter()
33
33
 
34
34
  APP_NAME = "interloper_agent"
35
35
 
36
- # ---------------------------------------------------------------------------
37
- # Lazy runner singleton
38
- # ---------------------------------------------------------------------------
36
+ # -- Lazy runner singleton -----------------------------------------------------
39
37
 
40
38
  _runner: Runner | None = None
41
39
  _session_service: InMemorySessionService | None = None
@@ -79,9 +77,7 @@ def _get_runner(store: Store, catalog: Catalog) -> Runner:
79
77
  return _runner
80
78
 
81
79
 
82
- # ---------------------------------------------------------------------------
83
- # Request / response models
84
- # ---------------------------------------------------------------------------
80
+ # -- Request / response models -------------------------------------------------
85
81
 
86
82
 
87
83
  class ChatRequest(BaseModel):
@@ -113,9 +109,7 @@ def _session_to_response(session: Any) -> SessionResponse:
113
109
  )
114
110
 
115
111
 
116
- # ---------------------------------------------------------------------------
117
- # Endpoints
118
- # ---------------------------------------------------------------------------
112
+ # -- Endpoints -----------------------------------------------------------------
119
113
 
120
114
 
121
115
  @router.post("/sessions")
@@ -131,6 +131,11 @@ def google_callback(
131
131
  avatar_url=avatar_url,
132
132
  )
133
133
 
134
+ # Bootstrap super-admins from settings. Promote-only: removing an email from
135
+ # the list never demotes an existing super-admin.
136
+ if not profile.is_super_admin and email.lower() in auth_config.super_admin_emails:
137
+ profile = store.set_super_admin(profile.id)
138
+
134
139
  # Create session (no org context — frontend resolves org after login)
135
140
  token = store.create_session(user_id=profile.id)
136
141
 
@@ -11,17 +11,34 @@ from interloper_db import Profile, Store
11
11
  from interloper_db.models import Backfill
12
12
  from pydantic import BaseModel
13
13
 
14
- from interloper_api.dependencies import authorize_org_member, get_current_user, get_org_id, get_store, require_viewer
14
+ from interloper_api.dependencies import (
15
+ authorize_org_member,
16
+ get_current_user,
17
+ get_org_id,
18
+ get_store,
19
+ load_authorized,
20
+ require_viewer,
21
+ )
15
22
 
16
23
  router = APIRouter()
17
24
 
18
25
 
26
+ class BackfillCreateRequest(BaseModel):
27
+ """Request body for queuing a backfill."""
28
+
29
+ component_id: UUID
30
+ start_date: dt.date
31
+ end_date: dt.date
32
+ concurrency: int = 1
33
+ fail_fast: bool = False
34
+
35
+
19
36
  class BackfillResponse(BaseModel):
20
37
  """Response body for a backfill."""
21
38
 
22
39
  id: UUID
23
40
  org_id: UUID
24
- job_id: UUID | None
41
+ component_id: UUID | None
25
42
  status: str
26
43
  start_date: dt.date
27
44
  end_date: dt.date
@@ -45,7 +62,7 @@ def _backfill_to_response(backfill: Backfill) -> BackfillResponse:
45
62
  return BackfillResponse(
46
63
  id=backfill.id,
47
64
  org_id=backfill.org_id,
48
- job_id=backfill.job_id,
65
+ component_id=backfill.component_id,
49
66
  status=backfill.status,
50
67
  start_date=backfill.start_date,
51
68
  end_date=backfill.end_date,
@@ -73,6 +90,27 @@ def list_backfills(
73
90
  return [_backfill_to_response(b) for b in backfills]
74
91
 
75
92
 
93
+ @router.post("/", status_code=201)
94
+ def create_backfill(
95
+ body: BackfillCreateRequest,
96
+ user: Profile = Depends(get_current_user),
97
+ store: Store = Depends(get_store),
98
+ ) -> BackfillResponse:
99
+ """Queue a backfill for a job over a date range."""
100
+ job = load_authorized(
101
+ lambda i: store.get_component(i, kind="job"), body.component_id, user, store, label="Job", minimum="editor"
102
+ )
103
+ backfill = store.create_backfill(
104
+ job.org_id,
105
+ component_id=body.component_id,
106
+ start_date=body.start_date,
107
+ end_date=body.end_date,
108
+ concurrency=body.concurrency,
109
+ fail_fast=body.fail_fast,
110
+ )
111
+ return _backfill_to_response(backfill)
112
+
113
+
76
114
  @router.get("/{backfill_id}")
77
115
  def get_backfill(
78
116
  backfill_id: UUID,
@@ -18,6 +18,25 @@ def list_catalog(catalog: Catalog = Depends(get_catalog)) -> dict[str, Any]:
18
18
  return catalog.dump()
19
19
 
20
20
 
21
+ @router.get("/resource-kinds")
22
+ def list_resource_kinds(catalog: Catalog = Depends(get_catalog)) -> list[str]:
23
+ """Return distinct resource kinds from the catalog.
24
+
25
+ A resource kind is any registered kind anchored under ``Resource``
26
+ (currently ``connection`` and ``config``) — the kinds usable as
27
+ slot bindings on other components.
28
+ """
29
+ import interloper as il
30
+
31
+ return sorted(
32
+ {
33
+ defn.kind
34
+ for defn in catalog.components.values()
35
+ if (anchor := il.KINDS.get(defn.kind)) is not None and issubclass(anchor, il.Resource)
36
+ }
37
+ )
38
+
39
+
21
40
  @router.get("/{key}")
22
41
  def get_definition(key: str, catalog: Catalog = Depends(get_catalog)) -> dict[str, Any]:
23
42
  """Return a single component definition by key.