interloper-api 0.31.0__tar.gz → 0.33.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.
- {interloper_api-0.31.0 → interloper_api-0.33.0}/PKG-INFO +1 -1
- {interloper_api-0.31.0 → interloper_api-0.33.0}/pyproject.toml +1 -1
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/app.py +10 -13
- interloper_api-0.33.0/src/interloper_api/components.py +64 -0
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/dependencies.py +43 -2
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/routes/admin.py +31 -8
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/routes/agent.py +3 -9
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/routes/auth.py +1 -1
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/routes/backfills.py +41 -3
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/routes/catalog.py +19 -0
- interloper_api-0.33.0/src/interloper_api/routes/components.py +591 -0
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/routes/oauth.py +30 -41
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/routes/organisations.py +7 -4
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/routes/runs.py +43 -14
- interloper_api-0.31.0/src/interloper_api/routes/assets.py +0 -347
- interloper_api-0.31.0/src/interloper_api/routes/destinations.py +0 -141
- interloper_api-0.31.0/src/interloper_api/routes/external/__init__.py +0 -40
- interloper_api-0.31.0/src/interloper_api/routes/external/resolve.py +0 -88
- interloper_api-0.31.0/src/interloper_api/routes/jobs.py +0 -240
- interloper_api-0.31.0/src/interloper_api/routes/resources.py +0 -218
- interloper_api-0.31.0/src/interloper_api/routes/sources.py +0 -222
- {interloper_api-0.31.0 → interloper_api-0.33.0}/README.md +0 -0
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/email.py +0 -0
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.31.0 → interloper_api-0.33.0}/src/interloper_api/routes/ws.py +0 -0
|
@@ -9,23 +9,18 @@ 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
|
-
|
|
22
|
-
external,
|
|
23
|
-
jobs,
|
|
20
|
+
components,
|
|
24
21
|
oauth,
|
|
25
22
|
organisations,
|
|
26
|
-
resources,
|
|
27
23
|
runs,
|
|
28
|
-
sources,
|
|
29
24
|
ws,
|
|
30
25
|
)
|
|
31
26
|
from interloper_api.routes import catalog as catalog_routes
|
|
@@ -60,6 +55,11 @@ def create_app(
|
|
|
60
55
|
"""
|
|
61
56
|
app = FastAPI(title="Interloper API", lifespan=ws.realtime_lifespan, **kwargs)
|
|
62
57
|
|
|
58
|
+
@app.exception_handler(NotFoundError)
|
|
59
|
+
async def _not_found_handler(_request: Request, exc: NotFoundError) -> JSONResponse:
|
|
60
|
+
"""Store mutations raise NotFoundError on missing targets — a plain 404."""
|
|
61
|
+
return JSONResponse(status_code=404, content={"detail": str(exc)})
|
|
62
|
+
|
|
63
63
|
@app.exception_handler(ComponentDriftError)
|
|
64
64
|
async def _component_drift_handler(_request: Request, exc: ComponentDriftError) -> JSONResponse:
|
|
65
65
|
"""A stored component whose catalog key has drifted is a conflict, not a 500.
|
|
@@ -92,15 +92,10 @@ def create_app(
|
|
|
92
92
|
api.include_router(organisations.router, tags=["organisations"])
|
|
93
93
|
api.include_router(admin.router, tags=["admin"])
|
|
94
94
|
api.include_router(catalog_routes.router, prefix="/catalog", tags=["catalog"])
|
|
95
|
-
api.include_router(
|
|
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"])
|
|
95
|
+
api.include_router(components.router, prefix="/components", tags=["components"])
|
|
99
96
|
api.include_router(runs.router, prefix="/runs", tags=["runs"])
|
|
100
97
|
api.include_router(backfills.router, prefix="/backfills", tags=["backfills"])
|
|
101
|
-
api.include_router(assets.router, prefix="/assets", tags=["assets"])
|
|
102
98
|
api.include_router(oauth.router, tags=["oauth"])
|
|
103
|
-
api.include_router(external.router, prefix="/external", tags=["external"])
|
|
104
99
|
api.include_router(ws.router, tags=["ws"])
|
|
105
100
|
|
|
106
101
|
agent_available = False
|
|
@@ -125,4 +120,6 @@ def create_app(
|
|
|
125
120
|
|
|
126
121
|
app.include_router(api)
|
|
127
122
|
|
|
123
|
+
oauth.log_provider_status()
|
|
124
|
+
|
|
128
125
|
return app
|
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
@@ -134,7 +134,7 @@ def google_callback(
|
|
|
134
134
|
# Bootstrap super-admins from settings. Promote-only: removing an email from
|
|
135
135
|
# the list never demotes an existing super-admin.
|
|
136
136
|
if not profile.is_super_admin and email.lower() in auth_config.super_admin_emails:
|
|
137
|
-
profile = store.set_super_admin(profile.id)
|
|
137
|
+
profile = store.set_super_admin(profile.id)
|
|
138
138
|
|
|
139
139
|
# Create session (no org context — frontend resolves org after login)
|
|
140
140
|
token = store.create_session(user_id=profile.id)
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|