interloper-api 0.2.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 (27) hide show
  1. interloper_api-0.2.0/PKG-INFO +18 -0
  2. interloper_api-0.2.0/README.md +0 -0
  3. interloper_api-0.2.0/pyproject.toml +53 -0
  4. interloper_api-0.2.0/src/interloper_api/__init__.py +3 -0
  5. interloper_api-0.2.0/src/interloper_api/app.py +100 -0
  6. interloper_api-0.2.0/src/interloper_api/dependencies.py +293 -0
  7. interloper_api-0.2.0/src/interloper_api/email.py +79 -0
  8. interloper_api-0.2.0/src/interloper_api/routes/__init__.py +0 -0
  9. interloper_api-0.2.0/src/interloper_api/routes/agent.py +229 -0
  10. interloper_api-0.2.0/src/interloper_api/routes/assets.py +304 -0
  11. interloper_api-0.2.0/src/interloper_api/routes/auth.py +241 -0
  12. interloper_api-0.2.0/src/interloper_api/routes/backfills.py +87 -0
  13. interloper_api-0.2.0/src/interloper_api/routes/catalog.py +46 -0
  14. interloper_api-0.2.0/src/interloper_api/routes/destinations.py +118 -0
  15. interloper_api-0.2.0/src/interloper_api/routes/external/__init__.py +48 -0
  16. interloper_api-0.2.0/src/interloper_api/routes/external/amazon_ads.py +82 -0
  17. interloper_api-0.2.0/src/interloper_api/routes/external/facebook_ads.py +55 -0
  18. interloper_api-0.2.0/src/interloper_api/routes/external/google_ads.py +104 -0
  19. interloper_api-0.2.0/src/interloper_api/routes/external/pinterest_ads.py +77 -0
  20. interloper_api-0.2.0/src/interloper_api/routes/external/snapchat_ads.py +86 -0
  21. interloper_api-0.2.0/src/interloper_api/routes/jobs.py +216 -0
  22. interloper_api-0.2.0/src/interloper_api/routes/oauth.py +345 -0
  23. interloper_api-0.2.0/src/interloper_api/routes/organisations.py +278 -0
  24. interloper_api-0.2.0/src/interloper_api/routes/resources.py +180 -0
  25. interloper_api-0.2.0/src/interloper_api/routes/runs.py +177 -0
  26. interloper_api-0.2.0/src/interloper_api/routes/sources.py +187 -0
  27. interloper_api-0.2.0/src/interloper_api/routes/ws.py +164 -0
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.3
2
+ Name: interloper-api
3
+ Version: 0.2.0
4
+ Summary: Interloper FastAPI routes
5
+ Author: Guillaume Onfroy
6
+ Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
7
+ Requires-Dist: interloper-core
8
+ Requires-Dist: interloper-db
9
+ Requires-Dist: fastapi>=0.115.0
10
+ Requires-Dist: httpx>=0.28.0
11
+ Requires-Dist: psycopg2-binary>=2.9.0
12
+ Requires-Dist: uvicorn[standard]>=0.41.0
13
+ Requires-Dist: wsproto>=1.2.0
14
+ Requires-Dist: interloper-agent ; extra == 'agent'
15
+ Requires-Python: >=3.10
16
+ Provides-Extra: agent
17
+ Description-Content-Type: text/markdown
18
+
File without changes
@@ -0,0 +1,53 @@
1
+ # ###############
2
+ # PROJECT / UV
3
+ # ###############
4
+ [project]
5
+ name = "interloper-api"
6
+ version = "0.2.0"
7
+ description = "Interloper FastAPI routes"
8
+ readme = "README.md"
9
+ authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "interloper-core",
13
+ "interloper-db",
14
+ "fastapi>=0.115.0",
15
+ "httpx>=0.28.0",
16
+ "psycopg2-binary>=2.9.0",
17
+ "uvicorn[standard]>=0.41.0",
18
+ "wsproto>=1.2.0",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ agent = ["interloper-agent"]
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.11.5,<0.12"]
26
+ build-backend = "uv_build"
27
+
28
+ [tool.uv.sources]
29
+ interloper-core = { workspace = true }
30
+ interloper-db = { workspace = true }
31
+ interloper-agent = { workspace = true }
32
+
33
+ # ###############
34
+ # RUFF
35
+ # ###############
36
+ [tool.ruff]
37
+ line-length = 120
38
+
39
+ [tool.ruff.lint]
40
+ extend-select = ["E", "I", "UP", "ANN001", "ANN201", "ANN202"]
41
+
42
+ [tool.ruff.lint.per-file-ignores]
43
+ "__init__.py" = ["F401", "F403"]
44
+ "tests/**" = ["ANN", "F811"]
45
+
46
+ # ###############
47
+ # PYRIGHT
48
+ # ###############
49
+ [tool.pyright]
50
+ include = ["src"]
51
+ typeCheckingMode = "basic"
52
+ reportMissingParameterType = true
53
+ ignore = ["tests/**"]
@@ -0,0 +1,3 @@
1
+ from interloper_api.app import create_app
2
+
3
+ __all__ = ["create_app"]
@@ -0,0 +1,100 @@
1
+ """FastAPI application factory for the interloper API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, FastAPI
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from interloper.catalog.base import Catalog
10
+ from interloper_db import Store
11
+
12
+ from interloper_api.dependencies import set_auth_config, set_catalog, set_smtp_config, set_store
13
+ from interloper_api.routes import (
14
+ assets,
15
+ auth,
16
+ backfills,
17
+ destinations,
18
+ external,
19
+ jobs,
20
+ oauth,
21
+ organisations,
22
+ resources,
23
+ runs,
24
+ sources,
25
+ ws,
26
+ )
27
+ from interloper_api.routes import catalog as catalog_routes
28
+
29
+
30
+ def create_app(
31
+ store: Store | None = None,
32
+ catalog: Catalog | None = None,
33
+ auth_config: Any | None = None,
34
+ smtp_config: Any | None = None,
35
+ cors_origins: list[str] | None = None,
36
+ **kwargs: Any,
37
+ ) -> FastAPI:
38
+ """Create the FastAPI application with all routes.
39
+
40
+ Args:
41
+ store: The ``Store`` instance for persistence.
42
+ catalog: Catalog instance.
43
+ auth_config: ``AuthConfig`` instance for authentication settings.
44
+ smtp_config: ``SmtpConfig`` instance for sending invitation emails.
45
+ cors_origins: Allowed CORS origins. Only needed in dev mode for direct
46
+ WebSocket connections that bypass the Vite proxy.
47
+ **kwargs: Additional kwargs forwarded to ``FastAPI()``.
48
+
49
+ Returns:
50
+ The configured FastAPI application.
51
+ """
52
+ app = FastAPI(title="Interloper API", lifespan=ws.realtime_lifespan, **kwargs)
53
+
54
+ if cors_origins:
55
+ app.add_middleware(
56
+ CORSMiddleware, # type: ignore[arg-type]
57
+ allow_origins=cors_origins,
58
+ allow_credentials=True,
59
+ allow_methods=["*"],
60
+ allow_headers=["*"],
61
+ )
62
+
63
+ if store:
64
+ set_store(store)
65
+ if catalog:
66
+ set_catalog(catalog)
67
+ if auth_config:
68
+ set_auth_config(auth_config)
69
+ if smtp_config:
70
+ set_smtp_config(smtp_config)
71
+
72
+ api = APIRouter(prefix="/api")
73
+ api.include_router(auth.router, tags=["auth"])
74
+ api.include_router(organisations.router, tags=["organisations"])
75
+ api.include_router(catalog_routes.router, prefix="/catalog", tags=["catalog"])
76
+ api.include_router(resources.router, prefix="/resources", tags=["resources"])
77
+ api.include_router(sources.router, prefix="/sources", tags=["sources"])
78
+ api.include_router(destinations.router, prefix="/destinations", tags=["destinations"])
79
+ api.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
80
+ api.include_router(runs.router, prefix="/runs", tags=["runs"])
81
+ api.include_router(backfills.router, prefix="/backfills", tags=["backfills"])
82
+ api.include_router(assets.router, prefix="/assets", tags=["assets"])
83
+ api.include_router(oauth.router, tags=["oauth"])
84
+ api.include_router(external.router, prefix="/external", tags=["external"])
85
+ api.include_router(ws.router, tags=["ws"])
86
+
87
+ try:
88
+ from interloper_api.routes import agent as agent_routes
89
+
90
+ api.include_router(agent_routes.router, prefix="/agent", tags=["agent"])
91
+ except ImportError:
92
+ pass
93
+
94
+ @api.get("/health")
95
+ def health() -> dict[str, str]:
96
+ return {"status": "ok"}
97
+
98
+ app.include_router(api)
99
+
100
+ return app
@@ -0,0 +1,293 @@
1
+ """Shared FastAPI dependencies for store, catalog, auth, and RBAC."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from uuid import UUID
7
+
8
+ from fastapi import Cookie, Depends, HTTPException
9
+ from interloper.catalog.base import Catalog
10
+ from interloper_db import Organisation, Profile, Store
11
+ from interloper_db.models import Session as SessionModel
12
+
13
+ _store: Store | None = None
14
+ _catalog: Catalog | None = None
15
+ _auth_config: Any | None = None
16
+ _smtp_config: Any | None = None
17
+
18
+ # Role hierarchy: admin > editor > viewer
19
+ _ROLE_RANK = {"viewer": 0, "editor": 1, "admin": 2}
20
+
21
+
22
+ def set_store(store: Store) -> None:
23
+ """Set the global store instance.
24
+
25
+ Args:
26
+ store: The Store to use for all API operations.
27
+ """
28
+ global _store # noqa: PLW0603
29
+ _store = store
30
+
31
+
32
+ def set_catalog(catalog: Catalog) -> None:
33
+ """Set the global catalog instance.
34
+
35
+ Args:
36
+ catalog: The Catalog instance.
37
+ """
38
+ global _catalog # noqa: PLW0603
39
+ _catalog = catalog
40
+
41
+
42
+ def set_auth_config(auth_config: Any) -> None:
43
+ """Set the global auth config.
44
+
45
+ Args:
46
+ auth_config: The AuthConfig instance.
47
+ """
48
+ global _auth_config # noqa: PLW0603
49
+ _auth_config = auth_config
50
+
51
+
52
+ def get_store() -> Store:
53
+ """Return the global store instance.
54
+
55
+ Returns:
56
+ The Store.
57
+
58
+ Raises:
59
+ RuntimeError: If the store has not been set.
60
+ """
61
+ if _store is None:
62
+ raise RuntimeError("Store not initialized. Call set_store() first.")
63
+ return _store
64
+
65
+
66
+ def get_catalog() -> dict[str, Any]:
67
+ """Return the global catalog as a serialized dict.
68
+
69
+ Returns:
70
+ Mapping from component key to serialized definition.
71
+
72
+ Raises:
73
+ RuntimeError: If the catalog has not been set.
74
+ """
75
+ if _catalog is None:
76
+ raise RuntimeError("Catalog not initialized. Call set_catalog() first.")
77
+ return _catalog.dump()
78
+
79
+
80
+ def get_auth_config() -> Any:
81
+ """Return the global auth config.
82
+
83
+ Returns:
84
+ The AuthConfig instance.
85
+
86
+ Raises:
87
+ RuntimeError: If the auth config has not been set.
88
+ """
89
+ if _auth_config is None:
90
+ raise RuntimeError("Auth config not initialized. Call set_auth_config() first.")
91
+ return _auth_config
92
+
93
+
94
+ def set_smtp_config(smtp_config: Any) -> None:
95
+ """Set the global SMTP config.
96
+
97
+ Args:
98
+ smtp_config: The SmtpConfig instance.
99
+ """
100
+ global _smtp_config # noqa: PLW0603
101
+ _smtp_config = smtp_config
102
+
103
+
104
+ def get_smtp_config() -> Any:
105
+ """Return the global SMTP config.
106
+
107
+ Returns:
108
+ The SmtpConfig instance, or None if not configured.
109
+ """
110
+ return _smtp_config
111
+
112
+
113
+ # -- Auth dependencies -------------------------------------------------------
114
+
115
+
116
+ def get_current_user(
117
+ store: Store = Depends(get_store),
118
+ session_token: str | None = Cookie(default=None),
119
+ ) -> Profile:
120
+ """Resolve the current user from the session cookie.
121
+
122
+ Args:
123
+ store: The Store instance.
124
+ session_token: Session cookie value.
125
+
126
+ Returns:
127
+ The authenticated Profile.
128
+
129
+ Raises:
130
+ HTTPException: 401 if not authenticated or session invalid/expired.
131
+ """
132
+ if not session_token:
133
+ raise HTTPException(status_code=401, detail="Not authenticated")
134
+
135
+ result = store.resolve_session(session_token)
136
+ if not result:
137
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
138
+
139
+ profile, _ = result
140
+ return profile
141
+
142
+
143
+ def get_session_context(
144
+ store: Store = Depends(get_store),
145
+ session_token: str | None = Cookie(default=None),
146
+ ) -> tuple[Profile, SessionModel]:
147
+ """Resolve user and session from the cookie.
148
+
149
+ Args:
150
+ store: The Store instance.
151
+ session_token: Session cookie value.
152
+
153
+ Returns:
154
+ ``(Profile, Session)`` tuple.
155
+
156
+ Raises:
157
+ HTTPException: 401 if not authenticated.
158
+ """
159
+ if not session_token:
160
+ raise HTTPException(status_code=401, detail="Not authenticated")
161
+
162
+ result = store.resolve_session(session_token)
163
+ if not result:
164
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
165
+
166
+ return result
167
+
168
+
169
+ def get_current_org(
170
+ store: Store = Depends(get_store),
171
+ session_token: str | None = Cookie(default=None),
172
+ ) -> Organisation:
173
+ """Resolve the current organisation from the session.
174
+
175
+ Args:
176
+ store: The Store instance.
177
+ session_token: Session cookie value.
178
+
179
+ Returns:
180
+ The active Organisation.
181
+
182
+ Raises:
183
+ HTTPException: 400 if no organisation selected, 401 if not authenticated.
184
+ """
185
+ if not session_token:
186
+ raise HTTPException(status_code=401, detail="Not authenticated")
187
+
188
+ result = store.resolve_session(session_token)
189
+ if not result:
190
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
191
+
192
+ _, session_row = result
193
+ if not session_row.organisation_id:
194
+ raise HTTPException(status_code=400, detail="No organisation selected")
195
+
196
+ org = store.get_organisation(session_row.organisation_id)
197
+ if not org:
198
+ raise HTTPException(status_code=404, detail="Organisation not found")
199
+
200
+ return org
201
+
202
+
203
+ def get_org_id(
204
+ org: Organisation = Depends(get_current_org),
205
+ ) -> UUID:
206
+ """Shorthand: return just the org UUID for route handlers.
207
+
208
+ Args:
209
+ org: The resolved Organisation.
210
+
211
+ Returns:
212
+ The organisation UUID.
213
+ """
214
+ return org.id # type: ignore[return-value]
215
+
216
+
217
+ # -- RBAC dependencies -------------------------------------------------------
218
+
219
+
220
+ def _check_role(
221
+ minimum: str,
222
+ user: Profile,
223
+ org_id: UUID,
224
+ store: Store,
225
+ ) -> Profile:
226
+ """Verify the user has at least the required role in the org.
227
+
228
+ Args:
229
+ minimum: Minimum role required (``viewer``, ``editor``, ``admin``).
230
+ user: The authenticated user.
231
+ org_id: The active organisation UUID.
232
+ store: The Store instance.
233
+
234
+ Returns:
235
+ The authenticated Profile (pass-through for dependency chaining).
236
+
237
+ Raises:
238
+ HTTPException: 403 if insufficient permissions.
239
+ """
240
+ role = store.get_user_role(user.id, org_id) # type: ignore[arg-type]
241
+ if role is None:
242
+ raise HTTPException(status_code=403, detail="Not a member of this organisation")
243
+ if _ROLE_RANK.get(role, -1) < _ROLE_RANK[minimum]:
244
+ raise HTTPException(status_code=403, detail=f"Requires {minimum} role or higher")
245
+ return user
246
+
247
+
248
+ def require_viewer(
249
+ user: Profile = Depends(get_current_user),
250
+ org_id: UUID = Depends(get_org_id),
251
+ store: Store = Depends(get_store),
252
+ ) -> Profile:
253
+ """Require at least ``viewer`` role. Any org member passes.
254
+
255
+ Returns:
256
+ The authenticated Profile.
257
+
258
+ Raises:
259
+ HTTPException: 401/403 on auth or role failure.
260
+ """
261
+ return _check_role("viewer", user, org_id, store)
262
+
263
+
264
+ def require_editor(
265
+ user: Profile = Depends(get_current_user),
266
+ org_id: UUID = Depends(get_org_id),
267
+ store: Store = Depends(get_store),
268
+ ) -> Profile:
269
+ """Require at least ``editor`` role.
270
+
271
+ Returns:
272
+ The authenticated Profile.
273
+
274
+ Raises:
275
+ HTTPException: 401/403 on auth or role failure.
276
+ """
277
+ return _check_role("editor", user, org_id, store)
278
+
279
+
280
+ def require_admin(
281
+ user: Profile = Depends(get_current_user),
282
+ org_id: UUID = Depends(get_org_id),
283
+ store: Store = Depends(get_store),
284
+ ) -> Profile:
285
+ """Require ``admin`` role.
286
+
287
+ Returns:
288
+ The authenticated Profile.
289
+
290
+ Raises:
291
+ HTTPException: 401/403 on auth or role failure.
292
+ """
293
+ return _check_role("admin", user, org_id, store)
@@ -0,0 +1,79 @@
1
+ """SMTP email utilities for sending invitation emails."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import smtplib
7
+ from email.mime.multipart import MIMEMultipart
8
+ from email.mime.text import MIMEText
9
+ from typing import Any
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def send_invite_email(
15
+ smtp_config: Any,
16
+ to: str,
17
+ org_name: str,
18
+ inviter_name: str,
19
+ invite_url: str,
20
+ ) -> None:
21
+ """Send an organisation invitation email via SMTP.
22
+
23
+ Args:
24
+ smtp_config: SmtpConfig instance with host, port, user, password, from_addr.
25
+ to: Recipient email address.
26
+ org_name: Name of the organisation.
27
+ inviter_name: Display name of the person who sent the invite.
28
+ invite_url: Full URL to accept the invitation.
29
+
30
+ Raises:
31
+ RuntimeError: If SMTP is not configured.
32
+ smtplib.SMTPException: If sending fails.
33
+ """
34
+ if not smtp_config.enabled:
35
+ raise RuntimeError("SMTP is not configured. Set smtp.host, smtp.user, and smtp.password.")
36
+
37
+ msg = MIMEMultipart("alternative")
38
+ msg["Subject"] = f"You've been invited to join {org_name} on Interloper"
39
+ msg["From"] = smtp_config.from_addr
40
+ msg["To"] = to
41
+
42
+ text = (
43
+ f"{inviter_name} has invited you to join the {org_name} organisation on Interloper.\n\n"
44
+ f"Click the link below to accept the invitation:\n{invite_url}\n\n"
45
+ "This invitation expires in 7 days."
46
+ )
47
+
48
+ html = f"""\
49
+ <html>
50
+ <body style="font-family: sans-serif; color: #333;">
51
+ <h2>You've been invited to join {org_name}</h2>
52
+ <p>{inviter_name} has invited you to join the <strong>{org_name}</strong> organisation on Interloper.</p>
53
+ <p>
54
+ <a href="{invite_url}"
55
+ style="display: inline-block; padding: 10px 20px; background: #2563eb;
56
+ color: #fff; text-decoration: none; border-radius: 6px;">
57
+ Accept Invitation
58
+ </a>
59
+ </p>
60
+ <p style="color: #666; font-size: 0.875rem;">This invitation expires in 7 days.</p>
61
+ </body>
62
+ </html>"""
63
+
64
+ msg.attach(MIMEText(text, "plain"))
65
+ msg.attach(MIMEText(html, "html"))
66
+
67
+ logger.info("Sending invite email to %s", to)
68
+
69
+ if smtp_config.port == 465:
70
+ with smtplib.SMTP_SSL(smtp_config.host, smtp_config.port) as server:
71
+ server.login(smtp_config.user, smtp_config.password)
72
+ server.sendmail(smtp_config.from_addr, to, msg.as_string())
73
+ else:
74
+ with smtplib.SMTP(smtp_config.host, smtp_config.port) as server:
75
+ server.starttls()
76
+ server.login(smtp_config.user, smtp_config.password)
77
+ server.sendmail(smtp_config.from_addr, to, msg.as_string())
78
+
79
+ logger.info("Invite email sent to %s", to)