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
interloper_api/app.py
ADDED
|
@@ -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)
|
interloper_api/email.py
ADDED
|
@@ -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)
|
|
File without changes
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Agent API: ADK-powered chat sessions with SSE streaming.
|
|
2
|
+
|
|
3
|
+
Provides endpoints for creating agent chat sessions, sending messages,
|
|
4
|
+
and streaming responses. The ADK Runner executes the interloper agent
|
|
5
|
+
in-process, reusing the API's authenticated Store and catalog.
|
|
6
|
+
|
|
7
|
+
Available when ``interloper-agent`` is installed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from collections.abc import AsyncIterator
|
|
14
|
+
from typing import Any
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
18
|
+
from fastapi.responses import StreamingResponse
|
|
19
|
+
from google.adk.apps import App
|
|
20
|
+
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
|
21
|
+
from google.adk.runners import Runner
|
|
22
|
+
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
|
23
|
+
from google.genai import types
|
|
24
|
+
from interloper.catalog.base import Catalog
|
|
25
|
+
from interloper_db import Profile, Store
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
from interloper_api.dependencies import get_catalog, get_org_id, get_store, require_editor, require_viewer
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
router = APIRouter()
|
|
33
|
+
|
|
34
|
+
APP_NAME = "interloper_agent"
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Lazy runner singleton
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
_runner: Runner | None = None
|
|
41
|
+
_session_service: InMemorySessionService | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_session_service() -> InMemorySessionService:
|
|
45
|
+
"""Return the shared session service, creating it on first call."""
|
|
46
|
+
global _session_service # noqa: PLW0603
|
|
47
|
+
if _session_service is None:
|
|
48
|
+
_session_service = InMemorySessionService()
|
|
49
|
+
return _session_service
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_runner(store: Store, catalog: Catalog) -> Runner:
|
|
53
|
+
"""Return the shared Runner, creating it on first call.
|
|
54
|
+
|
|
55
|
+
On first call, injects the API's Store and catalog into the agent
|
|
56
|
+
context so that agent tools can access them.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
store: The Store instance (from API dependencies).
|
|
60
|
+
catalog: The Catalog instance (from API dependencies).
|
|
61
|
+
"""
|
|
62
|
+
global _runner # noqa: PLW0603
|
|
63
|
+
if _runner is None:
|
|
64
|
+
from interloper_agent.agent import root_agent
|
|
65
|
+
from interloper_agent.context import set_catalog, set_store
|
|
66
|
+
|
|
67
|
+
if store is not None:
|
|
68
|
+
set_store(store)
|
|
69
|
+
if catalog is not None:
|
|
70
|
+
set_catalog(catalog)
|
|
71
|
+
|
|
72
|
+
app = App(name=APP_NAME, root_agent=root_agent)
|
|
73
|
+
_runner = Runner(
|
|
74
|
+
app=app,
|
|
75
|
+
session_service=_get_session_service(),
|
|
76
|
+
artifact_service=InMemoryArtifactService(),
|
|
77
|
+
)
|
|
78
|
+
logger.info("Agent runner initialized")
|
|
79
|
+
return _runner
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Request / response models
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ChatRequest(BaseModel):
|
|
88
|
+
"""Request body for sending a chat message."""
|
|
89
|
+
|
|
90
|
+
message: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SessionResponse(BaseModel):
|
|
94
|
+
"""Response body for an agent session."""
|
|
95
|
+
|
|
96
|
+
id: str
|
|
97
|
+
user_id: str
|
|
98
|
+
app_name: str
|
|
99
|
+
state: dict[str, Any]
|
|
100
|
+
last_update_time: float
|
|
101
|
+
event_count: int
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _session_to_response(session: Any) -> SessionResponse:
|
|
105
|
+
"""Convert an ADK Session to a response model."""
|
|
106
|
+
return SessionResponse(
|
|
107
|
+
id=session.id,
|
|
108
|
+
user_id=session.user_id,
|
|
109
|
+
app_name=session.app_name,
|
|
110
|
+
state=session.state,
|
|
111
|
+
last_update_time=session.last_update_time,
|
|
112
|
+
event_count=len(session.events),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Endpoints
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.post("/sessions")
|
|
122
|
+
async def create_session(
|
|
123
|
+
user: Profile = Depends(require_editor),
|
|
124
|
+
org_id: UUID = Depends(get_org_id),
|
|
125
|
+
store: Store = Depends(get_store),
|
|
126
|
+
catalog: Catalog = Depends(get_catalog),
|
|
127
|
+
) -> SessionResponse:
|
|
128
|
+
"""Create a new agent chat session.
|
|
129
|
+
|
|
130
|
+
The authenticated user's ``org_id`` is injected into the ADK session
|
|
131
|
+
state so agent tools are scoped to the correct organisation.
|
|
132
|
+
"""
|
|
133
|
+
_get_runner(store=store, catalog=catalog)
|
|
134
|
+
session_service = _get_session_service()
|
|
135
|
+
session = await session_service.create_session(
|
|
136
|
+
app_name=APP_NAME,
|
|
137
|
+
user_id=str(user.id),
|
|
138
|
+
state={"org_id": str(org_id)},
|
|
139
|
+
)
|
|
140
|
+
return _session_to_response(session)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@router.get("/sessions")
|
|
144
|
+
async def list_sessions(
|
|
145
|
+
user: Profile = Depends(require_viewer),
|
|
146
|
+
) -> list[SessionResponse]:
|
|
147
|
+
"""List all agent sessions for the current user."""
|
|
148
|
+
session_service = _get_session_service()
|
|
149
|
+
result = await session_service.list_sessions(
|
|
150
|
+
app_name=APP_NAME,
|
|
151
|
+
user_id=str(user.id),
|
|
152
|
+
)
|
|
153
|
+
sessions = result.sessions if hasattr(result, "sessions") else result
|
|
154
|
+
return [_session_to_response(s) for s in sessions]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@router.get("/sessions/{session_id}")
|
|
158
|
+
async def get_session(
|
|
159
|
+
session_id: str,
|
|
160
|
+
user: Profile = Depends(require_viewer),
|
|
161
|
+
) -> Response:
|
|
162
|
+
"""Get a session with its full event history.
|
|
163
|
+
|
|
164
|
+
Returns the session object including all events (messages and tool calls).
|
|
165
|
+
"""
|
|
166
|
+
session_service = _get_session_service()
|
|
167
|
+
session = await session_service.get_session(
|
|
168
|
+
app_name=APP_NAME,
|
|
169
|
+
user_id=str(user.id),
|
|
170
|
+
session_id=session_id,
|
|
171
|
+
)
|
|
172
|
+
if not session:
|
|
173
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
174
|
+
return Response(
|
|
175
|
+
content=session.model_dump_json(exclude_none=True, by_alias=True),
|
|
176
|
+
media_type="application/json",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@router.delete("/sessions/{session_id}")
|
|
181
|
+
async def delete_session(
|
|
182
|
+
session_id: str,
|
|
183
|
+
user: Profile = Depends(require_editor),
|
|
184
|
+
) -> dict[str, str]:
|
|
185
|
+
"""Delete an agent session."""
|
|
186
|
+
session_service = _get_session_service()
|
|
187
|
+
session = await session_service.get_session(
|
|
188
|
+
app_name=APP_NAME,
|
|
189
|
+
user_id=str(user.id),
|
|
190
|
+
session_id=session_id,
|
|
191
|
+
)
|
|
192
|
+
if not session:
|
|
193
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
194
|
+
await session_service.delete_session(
|
|
195
|
+
app_name=APP_NAME,
|
|
196
|
+
user_id=str(user.id),
|
|
197
|
+
session_id=session_id,
|
|
198
|
+
)
|
|
199
|
+
return {"status": "deleted"}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@router.post("/sessions/{session_id}/chat")
|
|
203
|
+
async def chat(
|
|
204
|
+
session_id: str,
|
|
205
|
+
body: ChatRequest,
|
|
206
|
+
user: Profile = Depends(require_editor),
|
|
207
|
+
store: Store = Depends(get_store),
|
|
208
|
+
catalog: Catalog = Depends(get_catalog),
|
|
209
|
+
) -> StreamingResponse:
|
|
210
|
+
"""Send a message and stream the agent's response as SSE.
|
|
211
|
+
|
|
212
|
+
Each SSE ``data`` line contains a JSON-serialized ADK Event.
|
|
213
|
+
Events with ``content.parts`` containing ``text`` are the agent's
|
|
214
|
+
text responses. Events with ``function_call`` or ``function_response``
|
|
215
|
+
parts represent tool invocations.
|
|
216
|
+
"""
|
|
217
|
+
runner = _get_runner(store=store, catalog=catalog)
|
|
218
|
+
user_id = str(user.id)
|
|
219
|
+
message = types.Content(parts=[types.Part(text=body.message)], role="user")
|
|
220
|
+
|
|
221
|
+
async def event_stream() -> AsyncIterator[str]:
|
|
222
|
+
async for event in runner.run_async(
|
|
223
|
+
user_id=user_id,
|
|
224
|
+
session_id=session_id,
|
|
225
|
+
new_message=message,
|
|
226
|
+
):
|
|
227
|
+
yield f"data: {event.model_dump_json(exclude_none=True, by_alias=True)}\n\n"
|
|
228
|
+
|
|
229
|
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|