from __future__ import annotations from typing import Any import httpx from fastapi import APIRouter, Body, Depends, HTTPException, Query from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode from api.schemas.fabric_graph import ( FabricGraphEdgeRead, FabricGraphImportRead, FabricGraphIngestResult, FabricGraphNodeRead, FabricGraphPullRequest, FabricGraphSummary, ) from api.services.fabric_graph import ( FabricGraphValidationError, ingest_fabric_graph_export, record_fabric_graph_error, split_graph_ingest_body, ) router = APIRouter(prefix="/fabric", tags=["fabric"]) @router.post("/graph-exports", response_model=FabricGraphIngestResult) async def ingest_graph_export( body: dict[str, Any] = Body(...), source_repo_slug: str | None = Query(None), source_url: str | None = Query(None), session: AsyncSession = Depends(get_session), ) -> FabricGraphIngestResult: graph, metadata = split_graph_ingest_body(body) effective_source_repo_slug = ( source_repo_slug or metadata.get("source_repo_slug") or metadata.get("repo_slug") or "railiance-fabric" ) effective_source_url = source_url or metadata.get("source_url") requested_by = str(metadata.get("requested_by") or "api") try: import_run, created, idempotent = await ingest_fabric_graph_export( session, graph, source_repo_slug=str(effective_source_repo_slug), source_url=str(effective_source_url) if effective_source_url else None, requested_by=requested_by, ) except FabricGraphValidationError as exc: raise HTTPException(status_code=422, detail=exc.detail) from exc return FabricGraphIngestResult( import_run=FabricGraphImportRead.model_validate(import_run), created=created, idempotent=idempotent, node_count=import_run.node_count, edge_count=import_run.edge_count, ) @router.post("/graph-exports/pull", response_model=FabricGraphIngestResult) async def pull_graph_export( body: FabricGraphPullRequest | None = None, session: AsyncSession = Depends(get_session), ) -> FabricGraphIngestResult: request = body or FabricGraphPullRequest() try: async with httpx.AsyncClient(timeout=15.0) as client: response = await client.get(request.source_url) response.raise_for_status() payload = response.json() except Exception as exc: await record_fabric_graph_error( session, "Fabric graph export pull failed.", source_repo_slug=request.source_repo_slug, source_url=request.source_url, error=str(exc), requested_by=request.requested_by, ) raise HTTPException(status_code=502, detail=f"Fabric graph export pull failed: {exc}") from exc try: import_run, created, idempotent = await ingest_fabric_graph_export( session, payload, source_repo_slug=request.source_repo_slug, source_url=request.source_url, requested_by=request.requested_by, ) except FabricGraphValidationError as exc: raise HTTPException(status_code=422, detail=exc.detail) from exc return FabricGraphIngestResult( import_run=FabricGraphImportRead.model_validate(import_run), created=created, idempotent=idempotent, node_count=import_run.node_count, edge_count=import_run.edge_count, ) @router.get("/graph-exports", response_model=list[FabricGraphImportRead]) async def list_graph_imports( source_repo_slug: str | None = None, validation_status: str | None = None, limit: int = Query(50, ge=1, le=500), session: AsyncSession = Depends(get_session), ) -> list[FabricGraphImportRead]: query = select(FabricGraphImport) if source_repo_slug: query = query.where(FabricGraphImport.source_repo_slug == source_repo_slug) if validation_status: query = query.where(FabricGraphImport.validation_status == validation_status) query = query.order_by(FabricGraphImport.created_at.desc()).limit(limit) result = await session.execute(query) return [FabricGraphImportRead.model_validate(row) for row in result.scalars().all()] @router.get("/graph-exports/latest", response_model=FabricGraphImportRead) async def latest_graph_import( source_repo_slug: str = "railiance-fabric", session: AsyncSession = Depends(get_session), ) -> FabricGraphImportRead: import_run = await _latest_valid_import(session, source_repo_slug) if import_run is None: raise HTTPException(status_code=404, detail=f"No valid Fabric graph import for '{source_repo_slug}'") return FabricGraphImportRead.model_validate(import_run) @router.get("/graph/nodes", response_model=list[FabricGraphNodeRead]) async def list_graph_nodes( source_repo_slug: str = "railiance-fabric", domain: str | None = None, repo: str | None = None, canonical_category: str | None = None, fabric_id: str | None = None, subfabric_id: str | None = None, owner_actor_id: str | None = None, owner_role: str | None = None, ownership_resolution: str | None = None, cost_center_id: str | None = None, profit_center_id: str | None = None, evidence_state: str | None = None, evidence_review_state: str | None = None, mapping_fit: str | None = None, kind: str | None = None, unresolved_ownership: bool | None = None, limit: int = Query(100, ge=1, le=1000), session: AsyncSession = Depends(get_session), ) -> list[FabricGraphNodeRead]: import_run = await _latest_valid_import_or_404(session, source_repo_slug) query = select(FabricGraphNode).where(FabricGraphNode.import_id == import_run.id) if domain: query = query.where(FabricGraphNode.domain_slug == domain) if repo: query = query.where(FabricGraphNode.repo_slug == repo) if canonical_category: query = query.where(FabricGraphNode.canon_category == canonical_category) if fabric_id: query = query.where(FabricGraphNode.fabric_id == fabric_id) if subfabric_id: query = query.where(FabricGraphNode.subfabric_id == subfabric_id) if owner_actor_id: query = query.where(FabricGraphNode.owner_actor_id == owner_actor_id) if owner_role: query = query.where(FabricGraphNode.owner_role == owner_role) if ownership_resolution: query = query.where(FabricGraphNode.ownership_resolution == ownership_resolution) if cost_center_id: query = query.where(FabricGraphNode.cost_center_id == cost_center_id) if profit_center_id: query = query.where(FabricGraphNode.profit_center_id == profit_center_id) if evidence_state: query = query.where(FabricGraphNode.evidence_state == evidence_state) if evidence_review_state: query = query.where(FabricGraphNode.evidence_review_state == evidence_review_state) if mapping_fit: query = query.where(FabricGraphNode.mapping_fit == mapping_fit) if kind: query = query.where(FabricGraphNode.kind == kind) if unresolved_ownership is True: query = query.where( or_( FabricGraphNode.owner_actor_id.is_(None), FabricGraphNode.ownership_resolution.in_(("unresolved", "ambiguous")), ) ) elif unresolved_ownership is False: query = query.where( FabricGraphNode.owner_actor_id.is_not(None), ~FabricGraphNode.ownership_resolution.in_(("unresolved", "ambiguous")), ) query = query.order_by(FabricGraphNode.graph_id).limit(limit) result = await session.execute(query) return [FabricGraphNodeRead.model_validate(row) for row in result.scalars().all()] @router.get("/graph/edges", response_model=list[FabricGraphEdgeRead]) async def list_graph_edges( source_repo_slug: str = "railiance-fabric", canonical_relationship: str | None = None, edge_type: str | None = None, relationship_category: str | None = None, provider_owner_actor_id: str | None = None, consumer_owner_actor_id: str | None = None, provider_fabric_id: str | None = None, consumer_fabric_id: str | None = None, provider_subfabric_id: str | None = None, consumer_subfabric_id: str | None = None, crosses_fabric_boundary: bool | None = None, crosses_subfabric_boundary: bool | None = None, utility_type: str | None = None, utility_business_model: str | None = None, utility_payment_schema_id: str | None = None, cost_center_id: str | None = None, profit_center_id: str | None = None, provider_profit_center_id: str | None = None, consumer_cost_center_id: str | None = None, evidence_state: str | None = None, evidence_review_state: str | None = None, mapping_fit: str | None = None, display_only: bool | None = None, missing_payment_schema: bool | None = None, from_graph_id: str | None = None, to_graph_id: str | None = None, limit: int = Query(100, ge=1, le=1000), session: AsyncSession = Depends(get_session), ) -> list[FabricGraphEdgeRead]: import_run = await _latest_valid_import_or_404(session, source_repo_slug) query = select(FabricGraphEdge).where(FabricGraphEdge.import_id == import_run.id) if canonical_relationship: query = query.where(FabricGraphEdge.canonical_type == canonical_relationship) if edge_type: query = query.where(FabricGraphEdge.edge_type == edge_type) if relationship_category: query = query.where(FabricGraphEdge.relationship_category == relationship_category) if provider_owner_actor_id: query = query.where(FabricGraphEdge.provider_owner_actor_id == provider_owner_actor_id) if consumer_owner_actor_id: query = query.where(FabricGraphEdge.consumer_owner_actor_id == consumer_owner_actor_id) if provider_fabric_id: query = query.where(FabricGraphEdge.provider_fabric_id == provider_fabric_id) if consumer_fabric_id: query = query.where(FabricGraphEdge.consumer_fabric_id == consumer_fabric_id) if provider_subfabric_id: query = query.where(FabricGraphEdge.provider_subfabric_id == provider_subfabric_id) if consumer_subfabric_id: query = query.where(FabricGraphEdge.consumer_subfabric_id == consumer_subfabric_id) if crosses_fabric_boundary is not None: query = query.where(FabricGraphEdge.crosses_fabric_boundary == crosses_fabric_boundary) if crosses_subfabric_boundary is not None: query = query.where(FabricGraphEdge.crosses_subfabric_boundary == crosses_subfabric_boundary) if utility_type: query = query.where(FabricGraphEdge.utility_type == utility_type) if utility_business_model: query = query.where(FabricGraphEdge.utility_business_model == utility_business_model) if utility_payment_schema_id: query = query.where(FabricGraphEdge.utility_payment_schema_id == utility_payment_schema_id) if cost_center_id: query = query.where(FabricGraphEdge.cost_center_id == cost_center_id) if profit_center_id: query = query.where(FabricGraphEdge.profit_center_id == profit_center_id) if provider_profit_center_id: query = query.where(FabricGraphEdge.provider_profit_center_id == provider_profit_center_id) if consumer_cost_center_id: query = query.where(FabricGraphEdge.consumer_cost_center_id == consumer_cost_center_id) if evidence_state: query = query.where(FabricGraphEdge.evidence_state == evidence_state) if evidence_review_state: query = query.where(FabricGraphEdge.evidence_review_state == evidence_review_state) if mapping_fit: query = query.where(FabricGraphEdge.mapping_fit == mapping_fit) if display_only is not None: query = query.where(FabricGraphEdge.display_only == display_only) if missing_payment_schema is True: query = query.where( FabricGraphEdge.relationship_category == "utility", FabricGraphEdge.utility_payment_schema_id.is_(None), ) elif missing_payment_schema is False: query = query.where( or_( FabricGraphEdge.relationship_category != "utility", FabricGraphEdge.utility_payment_schema_id.is_not(None), ) ) if from_graph_id: query = query.where(FabricGraphEdge.from_graph_id == from_graph_id) if to_graph_id: query = query.where(FabricGraphEdge.to_graph_id == to_graph_id) query = query.order_by(FabricGraphEdge.from_graph_id, FabricGraphEdge.to_graph_id, FabricGraphEdge.edge_type).limit(limit) result = await session.execute(query) return [FabricGraphEdgeRead.model_validate(row) for row in result.scalars().all()] @router.get("/graph/summary", response_model=FabricGraphSummary) async def graph_summary( source_repo_slug: str = "railiance-fabric", session: AsyncSession = Depends(get_session), ) -> FabricGraphSummary: import_run = await _latest_valid_import(session, source_repo_slug) if import_run is None: return FabricGraphSummary( source_repo_slug=source_repo_slug, latest_import=None, node_count=0, edge_count=0, schema_version=None, netkingdom_id=None, actor_count=0, fabric_count=0, unresolved_count=0, nodes_by_domain={}, nodes_by_repo={}, nodes_by_canon_category={}, nodes_by_fabric={}, nodes_by_subfabric={}, nodes_by_owner_actor={}, nodes_by_owner_role={}, nodes_by_ownership_resolution={}, edges_by_canonical_type={}, edges_by_relationship_category={}, utility_edges_by_provider_owner={}, utility_edges_by_consumer_owner={}, utility_edges_by_business_model={}, nodes_by_evidence_state={}, edges_by_evidence_state={}, nodes_by_mapping_fit={}, edges_by_mapping_fit={}, tenant_utilities_without_payment_schema=0, nodes_without_accounting_attribution=0, unresolved_ownership_count=0, unresolved_accounting_count=0, example_nodes=[], example_edges=[], ) return FabricGraphSummary( source_repo_slug=source_repo_slug, latest_import=FabricGraphImportRead.model_validate(import_run), node_count=import_run.node_count, edge_count=import_run.edge_count, schema_version=import_run.schema_version, netkingdom_id=import_run.netkingdom_id, actor_count=import_run.actor_count, fabric_count=import_run.fabric_count, unresolved_count=import_run.unresolved_count, nodes_by_domain=await _counts(session, FabricGraphNode.domain_slug, import_run.id), nodes_by_repo=await _counts(session, FabricGraphNode.repo_slug, import_run.id), nodes_by_canon_category=await _counts(session, FabricGraphNode.canon_category, import_run.id), nodes_by_fabric=await _counts(session, FabricGraphNode.fabric_id, import_run.id), nodes_by_subfabric=await _counts(session, FabricGraphNode.subfabric_id, import_run.id), nodes_by_owner_actor=await _counts(session, FabricGraphNode.owner_actor_id, import_run.id), nodes_by_owner_role=await _counts(session, FabricGraphNode.owner_role, import_run.id), nodes_by_ownership_resolution=await _counts(session, FabricGraphNode.ownership_resolution, import_run.id), edges_by_canonical_type=await _counts(session, FabricGraphEdge.canonical_type, import_run.id), edges_by_relationship_category=await _counts(session, FabricGraphEdge.relationship_category, import_run.id), utility_edges_by_provider_owner=await _counts( session, FabricGraphEdge.provider_owner_actor_id, import_run.id, FabricGraphEdge.relationship_category == "utility", ), utility_edges_by_consumer_owner=await _counts( session, FabricGraphEdge.consumer_owner_actor_id, import_run.id, FabricGraphEdge.relationship_category == "utility", ), utility_edges_by_business_model=await _counts( session, FabricGraphEdge.utility_business_model, import_run.id, FabricGraphEdge.relationship_category == "utility", ), nodes_by_evidence_state=await _counts(session, FabricGraphNode.evidence_state, import_run.id), edges_by_evidence_state=await _counts(session, FabricGraphEdge.evidence_state, import_run.id), nodes_by_mapping_fit=await _counts(session, FabricGraphNode.mapping_fit, import_run.id), edges_by_mapping_fit=await _counts(session, FabricGraphEdge.mapping_fit, import_run.id), tenant_utilities_without_payment_schema=await _count_rows( session, FabricGraphEdge, import_run.id, FabricGraphEdge.relationship_category == "utility", FabricGraphEdge.utility_business_model == "tenant_utility", FabricGraphEdge.utility_payment_schema_id.is_(None), ), nodes_without_accounting_attribution=await _count_rows( session, FabricGraphNode, import_run.id, FabricGraphNode.cost_center_id.is_(None), FabricGraphNode.profit_center_id.is_(None), ), unresolved_ownership_count=await _count_rows( session, FabricGraphNode, import_run.id, or_( FabricGraphNode.owner_actor_id.is_(None), FabricGraphNode.ownership_resolution.in_(("unresolved", "ambiguous")), ), ), unresolved_accounting_count=await _count_rows( session, FabricGraphEdge, import_run.id, FabricGraphEdge.relationship_category == "utility", FabricGraphEdge.utility_payment_schema_id.is_(None), ), example_nodes=await _example_nodes(session, import_run.id), example_edges=await _example_edges(session, import_run.id), ) async def _latest_valid_import(session: AsyncSession, source_repo_slug: str) -> FabricGraphImport | None: result = await session.execute( select(FabricGraphImport) .where( FabricGraphImport.source_repo_slug == source_repo_slug, FabricGraphImport.validation_status == "valid", FabricGraphImport.is_latest.is_(True), ) .order_by(FabricGraphImport.created_at.desc()) .limit(1) ) return result.scalar_one_or_none() async def _latest_valid_import_or_404(session: AsyncSession, source_repo_slug: str) -> FabricGraphImport: import_run = await _latest_valid_import(session, source_repo_slug) if import_run is None: raise HTTPException(status_code=404, detail=f"No valid Fabric graph import for '{source_repo_slug}'") return import_run async def _counts(session: AsyncSession, column: Any, import_id: Any, *conditions: Any) -> dict[str, int]: table = column.class_ result = await session.execute( select(column, func.count()) .select_from(table) .where(table.import_id == import_id, column.is_not(None), *conditions) .group_by(column) ) return {str(key): int(count) for key, count in result.all() if key is not None} async def _count_rows(session: AsyncSession, table: Any, import_id: Any, *conditions: Any) -> int: result = await session.execute( select(func.count()).select_from(table).where(table.import_id == import_id, *conditions) ) return int(result.scalar_one()) async def _example_nodes(session: AsyncSession, import_id: Any) -> list[FabricGraphNodeRead]: result = await session.execute( select(FabricGraphNode) .where(FabricGraphNode.import_id == import_id) .order_by(FabricGraphNode.graph_id) .limit(5) ) return [FabricGraphNodeRead.model_validate(row) for row in result.scalars().all()] async def _example_edges(session: AsyncSession, import_id: Any) -> list[FabricGraphEdgeRead]: result = await session.execute( select(FabricGraphEdge) .where(FabricGraphEdge.import_id == import_id) .order_by(FabricGraphEdge.from_graph_id, FabricGraphEdge.to_graph_id, FabricGraphEdge.edge_type) .limit(5) ) return [FabricGraphEdgeRead.model_validate(row) for row in result.scalars().all()]