generated from coulomb/repo-seed
Add a local /services router (source of truth for the catalog itself):
- GET /services/catalog with hosting_type / development_type / maturity_level /
status filters (eager-loads all four extensions)
- GET /services/{slug}
- POST /services/catalog upsert-by-slug, applying the dimension extensions;
first_party.repo_slug resolves to a managed_repos FK.
Extensions are read/written via session.get (not the relationship attribute) to
avoid async lazy-load. /tpsc/* is left intact for dependency snapshots. 7 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
144 lines
5.5 KiB
Python
144 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.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"]
|