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,304 @@
1
+ """Assets API: CRUD endpoints for assets and dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from uuid import UUID
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException
9
+ from interloper.errors import DataNotFoundError, NotFoundError
10
+ from interloper_db import Profile, Store
11
+ from interloper_db.models import Asset, AssetResource, Destination, DestinationResource
12
+ from pydantic import BaseModel
13
+ from sqlmodel import Session, select
14
+
15
+ from interloper_api.dependencies import get_org_id, get_store, require_editor, require_viewer
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ # -- Request/Response models --------------------------------------------------
21
+
22
+
23
+ class AssetCreateRequest(BaseModel):
24
+ """Request body for creating a standalone asset."""
25
+
26
+ key: str
27
+ config: dict[str, Any] | None = None
28
+ resources: dict[str, str] | None = None
29
+ destination_ids: list[str] | None = None
30
+
31
+
32
+ class AssetUpdateRequest(BaseModel):
33
+ """Request body for updating an asset."""
34
+
35
+ materializable: bool | None = None
36
+ config: dict[str, Any] | None = None
37
+ resources: dict[str, str] | None = None
38
+ destination_ids: list[str] | None = None
39
+
40
+
41
+ class DestinationResponse(BaseModel):
42
+ """Nested destination in asset response."""
43
+
44
+ id: UUID
45
+ key: str
46
+ name: str | None = None
47
+ config: dict[str, Any] | None = None
48
+ resources: dict[str, str] = {}
49
+ created_at: str | None = None
50
+
51
+
52
+ class AssetResponse(BaseModel):
53
+ """Response body for an asset."""
54
+
55
+ id: UUID
56
+ source_id: UUID | None
57
+ org_id: UUID
58
+ key: str
59
+ materializable: bool
60
+ config: dict[str, Any] | None = None
61
+ resources: dict[str, str] = {}
62
+ destinations: list[DestinationResponse] = []
63
+ created_at: str | None = None
64
+
65
+
66
+ class DependencyResponse(BaseModel):
67
+ """Response body for an asset dependency edge."""
68
+
69
+ asset_id: UUID
70
+ upstream_asset_id: UUID
71
+
72
+
73
+ class AddDependencyRequest(BaseModel):
74
+ """Request body for adding an asset dependency."""
75
+
76
+ upstream_asset_id: UUID
77
+ param_name: str
78
+
79
+
80
+ class PartitionRowCountItem(BaseModel):
81
+ """A single partition row count entry."""
82
+
83
+ partition: str
84
+ row_count: int
85
+
86
+
87
+ class PartitionRowCountsResponse(BaseModel):
88
+ """Response body for partition row counts."""
89
+
90
+ asset_key: str
91
+ partition_column: str
92
+ counts: list[PartitionRowCountItem]
93
+
94
+
95
+ # -- Helpers ------------------------------------------------------------------
96
+
97
+
98
+ def _resource_map(junction_cls: type, fk_column: str, fk_value: UUID) -> dict[str, str]:
99
+ """Build a {slot_key: resource_id} map from junction rows."""
100
+ from interloper_db.engine import get_engine
101
+
102
+ col = getattr(junction_cls, fk_column)
103
+ with Session(get_engine()) as s:
104
+ rows = s.exec(select(junction_cls).where(col == fk_value)).all()
105
+ return {r.key: str(r.resource_id) for r in rows}
106
+
107
+
108
+ def _dest_to_response(dest: Destination) -> DestinationResponse:
109
+ return DestinationResponse(
110
+ id=dest.id, # type: ignore[arg-type]
111
+ key=dest.key,
112
+ name=dest.name,
113
+ config=dest.config,
114
+ resources=_resource_map(DestinationResource, "destination_id", dest.id), # type: ignore[arg-type]
115
+ created_at=str(dest.created_at) if dest.created_at else None,
116
+ )
117
+
118
+
119
+ def _asset_to_response(asset: Asset) -> AssetResponse:
120
+ """Convert a DB Asset to an AssetResponse."""
121
+ return AssetResponse(
122
+ id=asset.id, # type: ignore[arg-type]
123
+ source_id=asset.source_id,
124
+ org_id=asset.org_id,
125
+ key=asset.key,
126
+ materializable=asset.materializable,
127
+ config=asset.config,
128
+ resources=_resource_map(AssetResource, "asset_id", asset.id), # type: ignore[arg-type]
129
+ destinations=[_dest_to_response(d) for d in asset.destinations],
130
+ created_at=str(asset.created_at) if asset.created_at else None,
131
+ )
132
+
133
+
134
+ # -- CRUD endpoints -----------------------------------------------------------
135
+
136
+
137
+ @router.get("/")
138
+ def list_assets(
139
+ user: Profile = Depends(require_viewer),
140
+ org_id: UUID = Depends(get_org_id),
141
+ store: Store = Depends(get_store),
142
+ ) -> list[AssetResponse]:
143
+ """List all assets for the current organisation."""
144
+ assets = store.list_assets(org_id)
145
+ return [_asset_to_response(a) for a in assets]
146
+
147
+
148
+ @router.post("/", status_code=201)
149
+ def create_asset(
150
+ body: AssetCreateRequest,
151
+ user: Profile = Depends(require_editor),
152
+ org_id: UUID = Depends(get_org_id),
153
+ store: Store = Depends(get_store),
154
+ ) -> AssetResponse:
155
+ """Create a standalone asset."""
156
+ asset = store.create_asset(
157
+ org_id,
158
+ key=body.key,
159
+ config=body.config,
160
+ resources=body.resources,
161
+ destination_ids=body.destination_ids,
162
+ )
163
+ return _asset_to_response(asset)
164
+
165
+
166
+ @router.get("/dependencies")
167
+ def list_dependencies(
168
+ user: Profile = Depends(require_viewer),
169
+ org_id: UUID = Depends(get_org_id),
170
+ store: Store = Depends(get_store),
171
+ ) -> list[DependencyResponse]:
172
+ """List all asset dependency edges for the current organisation."""
173
+ deps = store.list_dependencies(org_id)
174
+ return [
175
+ DependencyResponse(
176
+ asset_id=d.asset_id,
177
+ upstream_asset_id=d.upstream_asset_id,
178
+ )
179
+ for d in deps
180
+ ]
181
+
182
+
183
+ @router.get("/{asset_id}")
184
+ def get_asset(
185
+ asset_id: UUID,
186
+ user: Profile = Depends(require_viewer),
187
+ store: Store = Depends(get_store),
188
+ ) -> AssetResponse:
189
+ """Get a single asset by ID."""
190
+ try:
191
+ asset = store.get_asset(asset_id)
192
+ except NotFoundError:
193
+ raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
194
+ return _asset_to_response(asset)
195
+
196
+
197
+ @router.put("/{asset_id}")
198
+ def update_asset(
199
+ asset_id: UUID,
200
+ body: AssetUpdateRequest,
201
+ user: Profile = Depends(require_editor),
202
+ store: Store = Depends(get_store),
203
+ ) -> AssetResponse:
204
+ """Update an asset's configuration, resources, or destinations."""
205
+ try:
206
+ asset = store.update_asset(
207
+ asset_id,
208
+ materializable=body.materializable,
209
+ config=body.config,
210
+ resources=body.resources,
211
+ destination_ids=body.destination_ids,
212
+ )
213
+ except NotFoundError:
214
+ raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
215
+ return _asset_to_response(asset)
216
+
217
+
218
+ @router.delete("/{asset_id}")
219
+ def delete_asset(
220
+ asset_id: UUID,
221
+ user: Profile = Depends(require_editor),
222
+ store: Store = Depends(get_store),
223
+ ) -> dict[str, str]:
224
+ """Delete a standalone asset."""
225
+ try:
226
+ store.delete_asset(asset_id)
227
+ except NotFoundError:
228
+ raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
229
+ except ValueError as e:
230
+ raise HTTPException(status_code=400, detail=str(e))
231
+ return {"status": "deleted"}
232
+
233
+
234
+ # -- Dependency endpoints -----------------------------------------------------
235
+
236
+
237
+ @router.post("/{asset_id}/dependencies", status_code=201)
238
+ def add_dependency(
239
+ asset_id: UUID,
240
+ body: AddDependencyRequest,
241
+ user: Profile = Depends(require_editor),
242
+ store: Store = Depends(get_store),
243
+ ) -> DependencyResponse:
244
+ """Add a dependency between two assets."""
245
+ try:
246
+ dep = store.add_dependency(asset_id, body.upstream_asset_id, body.param_name)
247
+ except NotFoundError as e:
248
+ raise HTTPException(status_code=404, detail=str(e))
249
+ return DependencyResponse(
250
+ asset_id=dep.asset_id,
251
+ upstream_asset_id=dep.upstream_asset_id,
252
+ )
253
+
254
+
255
+ @router.delete("/{asset_id}/dependencies/{upstream_asset_id}", status_code=204)
256
+ def remove_dependency(
257
+ asset_id: UUID,
258
+ upstream_asset_id: UUID,
259
+ user: Profile = Depends(require_editor),
260
+ store: Store = Depends(get_store),
261
+ ) -> None:
262
+ """Remove an asset dependency."""
263
+ try:
264
+ store.remove_dependency(asset_id, upstream_asset_id)
265
+ except NotFoundError as e:
266
+ raise HTTPException(status_code=404, detail=str(e))
267
+
268
+
269
+ # -- Partition endpoint -------------------------------------------------------
270
+
271
+
272
+ @router.get("/{asset_id}/partition-row-counts")
273
+ def get_partition_row_counts(
274
+ asset_id: UUID,
275
+ user: Profile = Depends(require_viewer),
276
+ org_id: UUID = Depends(get_org_id),
277
+ store: Store = Depends(get_store),
278
+ ) -> PartitionRowCountsResponse:
279
+ """Get row counts grouped by partition for an asset."""
280
+ try:
281
+ il_asset = store.load_asset(asset_id)
282
+ except NotFoundError as e:
283
+ raise HTTPException(status_code=404, detail=str(e))
284
+
285
+ if not il_asset.partitioning:
286
+ raise HTTPException(status_code=400, detail="Asset is not partitioned")
287
+
288
+ try:
289
+ counts = il_asset.partition_row_counts()
290
+ except NotImplementedError:
291
+ raise HTTPException(status_code=400, detail="Destination does not support partition row counts")
292
+ except DataNotFoundError as e:
293
+ raise HTTPException(status_code=404, detail=str(e))
294
+ except Exception as e:
295
+ raise HTTPException(status_code=500, detail=str(e))
296
+
297
+ return PartitionRowCountsResponse(
298
+ asset_key=il_asset.key,
299
+ partition_column=il_asset.partitioning.column,
300
+ counts=[
301
+ PartitionRowCountItem(partition=str(k), row_count=v)
302
+ for k, v in sorted(counts.items())
303
+ ],
304
+ )
@@ -0,0 +1,241 @@
1
+ """Authentication routes — Google OAuth, session management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from urllib.parse import urlencode
7
+ from uuid import UUID
8
+
9
+ import httpx
10
+ from fastapi import APIRouter, Cookie, Depends, HTTPException, Response
11
+ from fastapi.responses import RedirectResponse
12
+ from interloper_db import Organisation, Profile, Store
13
+ from pydantic import BaseModel
14
+
15
+ from interloper_api.dependencies import get_auth_config, get_current_user, get_store
16
+
17
+ GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
18
+ GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
19
+ GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
20
+
21
+ router = APIRouter(prefix="/auth", tags=["auth"])
22
+
23
+
24
+ class OrganisationResponse(BaseModel):
25
+ """Organisation summary for auth responses."""
26
+
27
+ id: UUID
28
+ name: str
29
+ created_at: datetime | None = None
30
+
31
+
32
+ class AuthUserResponse(BaseModel):
33
+ """Current user with active organisation context."""
34
+
35
+ id: UUID
36
+ email: str
37
+ name: str | None = None
38
+ avatar_url: str | None = None
39
+ role: str
40
+ organisation: OrganisationResponse | None = None
41
+ last_organisation_id: UUID | None = None
42
+
43
+
44
+ @router.get("/google")
45
+ def google_login(
46
+ redirect: str | None = None,
47
+ auth_config: object = Depends(get_auth_config),
48
+ ) -> RedirectResponse:
49
+ """Redirect user to Google's OAuth consent screen."""
50
+ client_id = auth_config.google_client_id # type: ignore[attr-defined]
51
+ redirect_uri = auth_config.google_redirect_uri # type: ignore[attr-defined]
52
+
53
+ if not client_id:
54
+ raise HTTPException(status_code=500, detail="Google OAuth not configured")
55
+
56
+ params = urlencode(
57
+ {
58
+ "client_id": client_id,
59
+ "redirect_uri": redirect_uri,
60
+ "response_type": "code",
61
+ "scope": "openid email profile",
62
+ "access_type": "offline",
63
+ "prompt": "consent",
64
+ "state": redirect or "/",
65
+ }
66
+ )
67
+ return RedirectResponse(url=f"{GOOGLE_AUTH_URL}?{params}")
68
+
69
+
70
+ @router.get("/google/callback")
71
+ def google_callback(
72
+ code: str,
73
+ state: str | None = None,
74
+ store: Store = Depends(get_store),
75
+ auth_config: object = Depends(get_auth_config),
76
+ ) -> RedirectResponse:
77
+ """Exchange Google authorization code for tokens, upsert profile, create session."""
78
+ client_id = auth_config.google_client_id # type: ignore[attr-defined]
79
+ client_secret = auth_config.google_client_secret # type: ignore[attr-defined]
80
+ redirect_uri = auth_config.google_redirect_uri # type: ignore[attr-defined]
81
+ cookie_secure: bool = auth_config.cookie_secure # type: ignore[attr-defined]
82
+ session_expiry_days: int = auth_config.session_expiry_days # type: ignore[attr-defined]
83
+
84
+ if not client_id or not client_secret:
85
+ raise HTTPException(status_code=500, detail="Google OAuth not configured")
86
+
87
+ # Exchange code for tokens
88
+ token_resp = httpx.post(
89
+ GOOGLE_TOKEN_URL,
90
+ data={
91
+ "code": code,
92
+ "client_id": client_id,
93
+ "client_secret": client_secret,
94
+ "redirect_uri": redirect_uri,
95
+ "grant_type": "authorization_code",
96
+ },
97
+ )
98
+ if token_resp.status_code != 200:
99
+ raise HTTPException(status_code=401, detail="Failed to exchange authorization code")
100
+
101
+ tokens = token_resp.json()
102
+ access_token = tokens.get("access_token")
103
+ if not access_token:
104
+ raise HTTPException(status_code=401, detail="No access token in response")
105
+
106
+ # Fetch user info
107
+ userinfo_resp = httpx.get(
108
+ GOOGLE_USERINFO_URL,
109
+ headers={"Authorization": f"Bearer {access_token}"},
110
+ )
111
+ if userinfo_resp.status_code != 200:
112
+ raise HTTPException(status_code=401, detail="Failed to fetch user info")
113
+
114
+ userinfo = userinfo_resp.json()
115
+ google_id = userinfo.get("id")
116
+ email = userinfo.get("email")
117
+ name = userinfo.get("name")
118
+ avatar_url = userinfo.get("picture")
119
+
120
+ if not google_id or not email:
121
+ raise HTTPException(status_code=401, detail="Incomplete user info from Google")
122
+
123
+ # Upsert profile
124
+ profile = store.upsert_profile(
125
+ google_id=google_id,
126
+ email=email,
127
+ name=name,
128
+ avatar_url=avatar_url,
129
+ )
130
+
131
+ # Create session (no org context — frontend resolves org after login)
132
+ token = store.create_session(user_id=profile.id) # type: ignore[arg-type]
133
+
134
+ redirect_url = state if state and state.startswith("/") else "/"
135
+ response = RedirectResponse(url=redirect_url, status_code=302)
136
+ response.set_cookie(
137
+ key="session_token",
138
+ value=token,
139
+ httponly=True,
140
+ samesite="lax",
141
+ secure=cookie_secure,
142
+ max_age=session_expiry_days * 86400,
143
+ path="/",
144
+ )
145
+ return response
146
+
147
+
148
+ @router.post("/logout")
149
+ def logout(
150
+ response: Response,
151
+ user: Profile = Depends(get_current_user),
152
+ store: Store = Depends(get_store),
153
+ ) -> dict[str, str]:
154
+ """Delete all sessions for the current user and clear the cookie."""
155
+ store.delete_user_sessions(user.id) # type: ignore[arg-type]
156
+ response.delete_cookie("session_token", path="/")
157
+ return {"status": "ok"}
158
+
159
+
160
+ @router.get("/me")
161
+ def get_me(
162
+ store: Store = Depends(get_store),
163
+ session_token: str | None = Cookie(default=None),
164
+ ) -> AuthUserResponse:
165
+ """Return the current user and their active organisation (if any)."""
166
+ if not session_token:
167
+ raise HTTPException(status_code=401, detail="Not authenticated")
168
+
169
+ result = store.resolve_session(session_token)
170
+ if not result:
171
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
172
+
173
+ profile, session_row = result
174
+ org: Organisation | None = None
175
+ role = "viewer"
176
+
177
+ if session_row.organisation_id:
178
+ org = store.get_organisation(session_row.organisation_id)
179
+
180
+ if org and profile.id:
181
+ user_role = store.get_user_role(profile.id, org.id) # type: ignore[arg-type]
182
+ if user_role:
183
+ role = user_role
184
+
185
+ return AuthUserResponse(
186
+ id=profile.id, # type: ignore[arg-type]
187
+ email=profile.email,
188
+ name=profile.name,
189
+ avatar_url=profile.avatar_url,
190
+ role=role,
191
+ organisation=OrganisationResponse.model_validate(org, from_attributes=True) if org else None,
192
+ last_organisation_id=profile.last_organisation_id,
193
+ )
194
+
195
+
196
+ class SwitchOrgRequest(BaseModel):
197
+ """Request body for switching the active organisation."""
198
+
199
+ organisation_id: UUID
200
+
201
+
202
+ @router.post("/switch-org")
203
+ def switch_org(
204
+ body: SwitchOrgRequest,
205
+ user: Profile = Depends(get_current_user),
206
+ store: Store = Depends(get_store),
207
+ session_token: str | None = Cookie(default=None),
208
+ ) -> dict[str, str]:
209
+ """Switch the session's active organisation. User must be a member."""
210
+ role = store.get_user_role(user.id, body.organisation_id) # type: ignore[arg-type]
211
+ if not role:
212
+ raise HTTPException(status_code=403, detail="Not a member of this organisation")
213
+
214
+ if session_token:
215
+ store.set_session_org(session_token, body.organisation_id, user_id=user.id) # type: ignore[arg-type]
216
+ return {"status": "ok"}
217
+
218
+
219
+ class AcceptInviteRequest(BaseModel):
220
+ """Request body for accepting an invitation."""
221
+
222
+ token: str
223
+
224
+
225
+ @router.post("/accept-invite")
226
+ def accept_invite(
227
+ body: AcceptInviteRequest,
228
+ user: Profile = Depends(get_current_user),
229
+ store: Store = Depends(get_store),
230
+ session_token: str | None = Cookie(default=None),
231
+ ) -> dict[str, str]:
232
+ """Accept an organisation invitation using its token."""
233
+ org = store.accept_invitation(body.token, user.id) # type: ignore[arg-type]
234
+ if not org:
235
+ raise HTTPException(status_code=400, detail="Invalid or expired invitation")
236
+
237
+ # Switch to the new org
238
+ if session_token:
239
+ store.set_session_org(session_token, org.id, user_id=user.id) # type: ignore[arg-type]
240
+
241
+ return {"status": "ok"}
@@ -0,0 +1,87 @@
1
+ """Backfills API: read endpoints for backfill state."""
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 Backfill
12
+ from pydantic import BaseModel
13
+
14
+ from interloper_api.dependencies import get_org_id, get_store, require_viewer
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ class BackfillResponse(BaseModel):
20
+ """Response body for a backfill."""
21
+
22
+ id: UUID
23
+ org_id: UUID
24
+ job_id: UUID | None
25
+ status: str
26
+ start_date: dt.date
27
+ end_date: dt.date
28
+ concurrency: int
29
+ fail_fast: bool
30
+ partitions: int
31
+ started_at: str | None = None
32
+ completed_at: str | None = None
33
+ created_at: str | None = None
34
+
35
+
36
+ def _backfill_to_response(backfill: Backfill) -> BackfillResponse:
37
+ """Convert a DB Backfill to a BackfillResponse.
38
+
39
+ Args:
40
+ backfill: The DB Backfill row.
41
+
42
+ Returns:
43
+ The response model.
44
+ """
45
+ return BackfillResponse(
46
+ id=backfill.id, # type: ignore[arg-type]
47
+ org_id=backfill.org_id,
48
+ job_id=backfill.job_id,
49
+ status=backfill.status,
50
+ start_date=backfill.start_date,
51
+ end_date=backfill.end_date,
52
+ concurrency=backfill.concurrency,
53
+ fail_fast=backfill.fail_fast,
54
+ partitions=backfill.partitions,
55
+ started_at=str(backfill.started_at) if backfill.started_at else None,
56
+ completed_at=str(backfill.completed_at) if backfill.completed_at else None,
57
+ created_at=str(backfill.created_at) if backfill.created_at else None,
58
+ )
59
+
60
+
61
+ @router.get("/")
62
+ def list_backfills(
63
+ active_only: bool = False,
64
+ user: Profile = Depends(require_viewer),
65
+ org_id: UUID = Depends(get_org_id),
66
+ store: Store = Depends(get_store),
67
+ ) -> list[BackfillResponse]:
68
+ """List backfills for the current organisation."""
69
+ if active_only:
70
+ backfills = store.list_active_backfills(org_id)
71
+ else:
72
+ backfills = store.list_backfills(org_id)
73
+ return [_backfill_to_response(b) for b in backfills]
74
+
75
+
76
+ @router.get("/{backfill_id}")
77
+ def get_backfill(
78
+ backfill_id: UUID,
79
+ user: Profile = Depends(require_viewer),
80
+ store: Store = Depends(get_store),
81
+ ) -> BackfillResponse:
82
+ """Get a single backfill by ID."""
83
+ try:
84
+ backfill = store.get_backfill(backfill_id)
85
+ except NotFoundError:
86
+ raise HTTPException(status_code=404, detail=f"Backfill {backfill_id} not found")
87
+ return _backfill_to_response(backfill)
@@ -0,0 +1,46 @@
1
+ """Catalog API: serves component definitions for frontend consumption."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Depends
8
+
9
+ from interloper_api.dependencies import get_catalog
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.get("/")
15
+ def list_catalog(catalog: dict[str, Any] = Depends(get_catalog)) -> dict[str, Any]:
16
+ """Return the full catalog."""
17
+ return catalog
18
+
19
+
20
+ @router.get("/{key}")
21
+ def get_definition(key: str, catalog: dict[str, Any] = Depends(get_catalog)) -> dict[str, Any]:
22
+ """Return a single component definition by key.
23
+
24
+ Args:
25
+ key: The component key.
26
+ catalog: Injected catalog.
27
+
28
+ Raises:
29
+ HTTPException: If the key is not found.
30
+ """
31
+ from fastapi import HTTPException
32
+
33
+ if key not in catalog:
34
+ raise HTTPException(status_code=404, detail=f"Component '{key}' not found in catalog")
35
+ return catalog[key]
36
+
37
+
38
+ @router.get("/kind/{kind}")
39
+ def list_by_kind(kind: str, catalog: dict[str, Any] = Depends(get_catalog)) -> dict[str, Any]:
40
+ """Return all catalog entries matching a kind (source, asset, resource, destination).
41
+
42
+ Args:
43
+ kind: The component kind to filter by.
44
+ catalog: Injected catalog.
45
+ """
46
+ return {k: v for k, v in catalog.items() if v.get("kind") == kind}