"""Two-dimension service catalog API (STATE-WP-0062). Read/write surface over service_catalog and its per-dimension extension tables. The four service classes are queried by combining the hosting_type and development_type filters. The legacy /tpsc routes remain for third-party dependency snapshots; this router is the source of truth for the catalog itself. """ import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from api.database import get_session from api.models.managed_repo import ManagedRepo from api.models.service_catalog import ( ServiceCatalog, ServiceCloud, ServiceFirstParty, ServiceSelfHosted, ServiceThirdParty, ) from api.schemas.service import ServiceCatalogRead, ServiceUpsert router = APIRouter(prefix="/services", tags=["services"]) _HOSTING = {"self_hosted", "cloud_hosted"} _DEVELOPMENT = {"first_party", "third_party"} _WITH_EXTENSIONS = ( selectinload(ServiceCatalog.third_party), selectinload(ServiceCatalog.first_party), selectinload(ServiceCatalog.cloud), selectinload(ServiceCatalog.self_hosted), ) @router.get("/catalog", response_model=list[ServiceCatalogRead]) async def list_services( hosting_type: str | None = None, development_type: str | None = None, maturity_level: int | None = None, status: str | None = None, session: AsyncSession = Depends(get_session), ) -> list[ServiceCatalog]: q = select(ServiceCatalog).options(*_WITH_EXTENSIONS) if hosting_type: q = q.where(ServiceCatalog.hosting_type == hosting_type) if development_type: q = q.where(ServiceCatalog.development_type == development_type) if maturity_level is not None: q = q.where(ServiceCatalog.maturity_level == maturity_level) if status: q = q.where(ServiceCatalog.status == status) q = q.order_by(ServiceCatalog.name.asc()) result = await session.execute(q) return list(result.scalars().all()) @router.get("/{slug}", response_model=ServiceCatalogRead) async def get_service( slug: str, session: AsyncSession = Depends(get_session), ) -> ServiceCatalog: svc = await _resolve(slug, session) if svc is None: raise HTTPException(status_code=404, detail=f"Service '{slug}' not found") return svc @router.post("/catalog", response_model=ServiceCatalogRead, status_code=status.HTTP_201_CREATED) async def upsert_service( body: ServiceUpsert, session: AsyncSession = Depends(get_session), ) -> ServiceCatalog: if body.hosting_type not in _HOSTING: raise HTTPException(status_code=422, detail=f"hosting_type must be one of {sorted(_HOSTING)}") if body.development_type not in _DEVELOPMENT: raise HTTPException(status_code=422, detail=f"development_type must be one of {sorted(_DEVELOPMENT)}") svc = await _resolve(body.slug, session) if svc is None: svc = ServiceCatalog(slug=body.slug) session.add(svc) for field in ("name", "owner_or_provider", "category", "description", "website_url", "status", "hosting_type", "development_type", "maturity_level"): setattr(svc, field, getattr(body, field)) await _apply_extensions(svc, body, session) await session.commit() return await _resolve(body.slug, session) # ── Helpers ────────────────────────────────────────────────────────────────── async def _resolve(slug: str, session: AsyncSession) -> ServiceCatalog | None: result = await session.execute( select(ServiceCatalog).where(ServiceCatalog.slug == slug).options(*_WITH_EXTENSIONS) ) return result.scalar_one_or_none() async def _upsert_ext(model, service_id: uuid.UUID, data: dict, session: AsyncSession) -> None: """Create or update a 1:1 extension row keyed by service_id. Fetched via session.get (not the relationship attribute) so we never trigger a lazy relationship load on a freshly-created core row in async context. """ current = await session.get(model, service_id) if current is None: current = model(service_id=service_id) session.add(current) for k, v in data.items(): setattr(current, k, v) async def _apply_extensions(svc: ServiceCatalog, body: ServiceUpsert, session: AsyncSession) -> None: # Ensure svc.id is available for new rows. await session.flush() if body.third_party is not None: await _upsert_ext(ServiceThirdParty, svc.id, body.third_party.model_dump(), session) if body.cloud is not None: await _upsert_ext(ServiceCloud, svc.id, body.cloud.model_dump(), session) if body.self_hosted is not None: await _upsert_ext(ServiceSelfHosted, svc.id, body.self_hosted.model_dump(), session) if body.first_party is not None: data = body.first_party.model_dump(exclude={"repo_slug"}) if body.first_party.repo_slug and not data.get("repo_id"): repo = (await session.execute( select(ManagedRepo).where(ManagedRepo.slug == body.first_party.repo_slug) )).scalar_one_or_none() if repo is None: raise HTTPException(status_code=404, detail=f"Repo '{body.first_party.repo_slug}' not found") data["repo_id"] = repo.id await _upsert_ext(ServiceFirstParty, svc.id, data, session) __all__ = ["router"]