# SPDX-License-Identifier: AGPL-3.0-or-later """Edge CRUD endpoints — pending-only child mutations. POST /topologies/{id}/edges DELETE /topologies/{id}/edges/{edge_id} Edges are the decky↔LAN membership table (bipartite). Creating an edge attaches a decky to an additional LAN; deleting one detaches. """ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Response, status from decnet.telemetry import traced as _traced from decnet.topology.status import ( TopologyNotEditable, VersionConflict, ) from decnet.web.db.models import EdgeCreateRequest, EdgeRow from decnet.web.dependencies import repo, require_admin from ._guards import assert_pending_or_409, map_repo_exception router = APIRouter() @router.post( "/{topology_id}/edges", tags=["MazeNET Topologies"], response_model=EdgeRow, status_code=status.HTTP_201_CREATED, responses={ 400: {"description": "Malformed body or unknown decky/LAN"}, 401: {"description": "Missing or invalid credentials"}, 403: {"description": "Insufficient permissions"}, 404: {"description": "Topology not found"}, 409: {"description": "Topology not editable or version conflict"}, }, ) @_traced("api.topology.edge.create") async def api_create_edge( topology_id: str, body: EdgeCreateRequest, _admin: dict = Depends(require_admin), ) -> EdgeRow: await assert_pending_or_409(topology_id) # Referential integrity: decky + LAN must belong to this topology. deckies = await repo.list_topology_deckies(topology_id) if not any(d.uuid == body.decky_uuid for d in deckies): raise HTTPException( status_code=400, detail=f"decky {body.decky_uuid!r} not in topology {topology_id!r}", ) lans = await repo.list_lans_for_topology(topology_id) if not any(r.id == body.lan_id for r in lans): raise HTTPException( status_code=400, detail=f"lan {body.lan_id!r} not in topology {topology_id!r}", ) payload = { "topology_id": topology_id, "decky_uuid": body.decky_uuid, "lan_id": body.lan_id, "is_bridge": body.is_bridge, "forwards_l3": body.forwards_l3, } try: edge_id = await repo.add_topology_edge( payload, expected_version=body.expected_version ) except (TopologyNotEditable, VersionConflict, ValueError) as exc: raise map_repo_exception(exc) from exc edges = await repo.list_topology_edges(topology_id) row = next((e for e in edges if e.id == edge_id), None) if row is None: # pragma: no cover raise HTTPException(status_code=500, detail="Edge insert vanished") return row @router.delete( "/{topology_id}/edges/{edge_id}", tags=["MazeNET Topologies"], status_code=status.HTTP_204_NO_CONTENT, responses={ 400: {"description": "Malformed path"}, 401: {"description": "Missing or invalid credentials"}, 403: {"description": "Insufficient permissions"}, 404: {"description": "Topology or edge not found"}, 409: {"description": "Topology not editable or version conflict"}, }, ) @_traced("api.topology.edge.delete") async def api_delete_edge( topology_id: str, edge_id: str, _admin: dict = Depends(require_admin), ) -> Response: await assert_pending_or_409(topology_id) edges = await repo.list_topology_edges(topology_id) if not any(e.id == edge_id for e in edges): raise HTTPException(status_code=404, detail="Edge not found") try: await repo.delete_topology_edge(edge_id) except (TopologyNotEditable, VersionConflict, ValueError) as exc: raise map_repo_exception(exc) from exc return Response(status_code=status.HTTP_204_NO_CONTENT)