state-hub/api/routers/services.py
tegwick 2e2ae1e5d0
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
feat: make repository reads alias-aware
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 10:47:51 +02:00

147 lines
5.5 KiB
Python

"""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.service_catalog import (
ServiceCatalog,
ServiceCloud,
ServiceFirstParty,
ServiceSelfHosted,
ServiceThirdParty,
)
from api.schemas.service import ServiceCatalogRead, ServiceUpsert
from api.services.repository_aliases import resolve_repository_slug
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,
repo_slug: 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)
if repo_slug:
resolution = await resolve_repository_slug(session, repo_slug)
q = q.join(ServiceFirstParty).where(
ServiceFirstParty.repo_id == resolution.repo.id
)
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"):
resolution = await resolve_repository_slug(
session, body.first_party.repo_slug
)
data["repo_id"] = resolution.repo.id
await _upsert_ext(ServiceFirstParty, svc.id, data, session)
__all__ = ["router"]