From 77689fbfb2b0bc774976ad8429498d914f662353 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 19 Jun 2026 20:56:19 +0200 Subject: [PATCH] STATE-WP-0062 T2: /services catalog API over the two-dimension model 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 --- api/main.py | 3 +- api/routers/services.py | 143 ++++++++++++++++++++++++++++++++++ api/schemas/service.py | 116 +++++++++++++++++++++++++++ tests/test_services_router.py | 93 ++++++++++++++++++++++ 4 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 api/routers/services.py create mode 100644 api/schemas/service.py create mode 100644 tests/test_services_router.py diff --git a/api/main.py b/api/main.py index d693183..4ac2d43 100644 --- a/api/main.py +++ b/api/main.py @@ -12,7 +12,7 @@ from starlette.responses import Response as StarletteResponse from api.database import engine from api.events import shutdown_publisher from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies -from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc +from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services from api.routers import token_events from api.routers import interface_changes from api.routers import flows @@ -120,6 +120,7 @@ app.include_router(sbom.router) app.include_router(messages.router) app.include_router(capability_requests.router) app.include_router(tpsc.router) +app.include_router(services.router) app.include_router(token_events.router) app.include_router(interface_changes.router) app.include_router(flows.router) diff --git a/api/routers/services.py b/api/routers/services.py new file mode 100644 index 0000000..763da99 --- /dev/null +++ b/api/routers/services.py @@ -0,0 +1,143 @@ +"""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"] diff --git a/api/schemas/service.py b/api/schemas/service.py new file mode 100644 index 0000000..7ef2202 --- /dev/null +++ b/api/schemas/service.py @@ -0,0 +1,116 @@ +"""Schemas for the two-dimension service catalog (STATE-WP-0062).""" +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +# ── Extension read models ──────────────────────────────────────────────────── + +class ServiceThirdPartyRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + pricing_model: str + upstream_packages: list | None = None + upstream_contacts: list | None = None + source_url: str | None = None + support_url: str | None = None + license: str | None = None + + +class ServiceFirstPartyRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + repo_id: uuid.UUID | None = None + owning_domain: str | None = None + + +class ServiceCloudRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + gdpr_maturity: str + gdpr_notes: str | None = None + dpa_available: bool + tos_url: str | None = None + privacy_policy_url: str | None = None + data_processing_regions: list | None = None + data_retention_notes: str | None = None + + +class ServiceSelfHostedRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + helix_instance: str | None = None + host_node: str | None = None + deployment_ref: str | None = None + runbook_ref: str | None = None + upstream_oss_project: str | None = None + + +class ServiceCatalogRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + slug: str + name: str + owner_or_provider: str | None = None + category: str | None = None + description: str | None = None + website_url: str | None = None + status: str + hosting_type: str + development_type: str + maturity_level: int | None = None + created_at: datetime + updated_at: datetime + third_party: ServiceThirdPartyRead | None = None + first_party: ServiceFirstPartyRead | None = None + cloud: ServiceCloudRead | None = None + self_hosted: ServiceSelfHostedRead | None = None + + +# ── Write (upsert) models ──────────────────────────────────────────────────── + +class ServiceThirdPartyIn(BaseModel): + pricing_model: str = "unknown" + upstream_packages: list | None = None + upstream_contacts: list | None = None + source_url: str | None = None + support_url: str | None = None + license: str | None = None + + +class ServiceFirstPartyIn(BaseModel): + repo_id: uuid.UUID | None = None + repo_slug: str | None = None + owning_domain: str | None = None + + +class ServiceCloudIn(BaseModel): + gdpr_maturity: str = "unknown" + gdpr_notes: str | None = None + dpa_available: bool = False + tos_url: str | None = None + privacy_policy_url: str | None = None + data_processing_regions: list | None = None + data_retention_notes: str | None = None + + +class ServiceSelfHostedIn(BaseModel): + helix_instance: str | None = None + host_node: str | None = None + deployment_ref: str | None = None + runbook_ref: str | None = None + upstream_oss_project: str | None = None + + +class ServiceUpsert(BaseModel): + slug: str + name: str + owner_or_provider: str | None = None + category: str | None = None + description: str | None = None + website_url: str | None = None + status: str = "active" + hosting_type: str # self_hosted | cloud_hosted + development_type: str # first_party | third_party + maturity_level: int | None = None + third_party: ServiceThirdPartyIn | None = None + first_party: ServiceFirstPartyIn | None = None + cloud: ServiceCloudIn | None = None + self_hosted: ServiceSelfHostedIn | None = None diff --git a/tests/test_services_router.py b/tests/test_services_router.py new file mode 100644 index 0000000..2c68069 --- /dev/null +++ b/tests/test_services_router.py @@ -0,0 +1,93 @@ +"""Tests for the two-dimension service catalog API (STATE-WP-0062).""" +from __future__ import annotations + + +def _svc(slug, hosting, development, **extra): + body = { + "slug": slug, + "name": slug.replace("-", " ").title(), + "hosting_type": hosting, + "development_type": development, + } + body.update(extra) + return body + + +async def test_upsert_cloud_third_party_with_extensions(client): + r = await client.post("/services/catalog", json=_svc( + "openai-api", "cloud_hosted", "third_party", + owner_or_provider="OpenAI", + cloud={"gdpr_maturity": "developing", "dpa_available": True}, + third_party={"pricing_model": "usage_based", "support_url": "https://help.openai.com"}, + )) + assert r.status_code == 201, r.text + body = r.json() + assert body["hosting_type"] == "cloud_hosted" + assert body["development_type"] == "third_party" + assert body["cloud"]["gdpr_maturity"] == "developing" + assert body["cloud"]["dpa_available"] is True + assert body["third_party"]["pricing_model"] == "usage_based" + assert body["first_party"] is None + assert body["self_hosted"] is None + + +async def test_upsert_self_hosted_first_party_with_maturity(client): + r = await client.post("/services/catalog", json=_svc( + "state-hub", "self_hosted", "first_party", + maturity_level=2, + first_party={"owning_domain": "custodian"}, + self_hosted={"helix_instance": "coulombcore", "runbook_ref": "make api"}, + )) + assert r.status_code == 201, r.text + body = r.json() + assert body["maturity_level"] == 2 + assert body["first_party"]["owning_domain"] == "custodian" + assert body["self_hosted"]["helix_instance"] == "coulombcore" + assert body["cloud"] is None + + +async def test_dimension_filters(client): + await client.post("/services/catalog", json=_svc("gitea", "self_hosted", "third_party", + self_hosted={"upstream_oss_project": "Gitea"})) + await client.post("/services/catalog", json=_svc("stripe", "cloud_hosted", "third_party")) + await client.post("/services/catalog", json=_svc("hub", "self_hosted", "first_party", maturity_level=3)) + + self_hosted = (await client.get("/services/catalog?hosting_type=self_hosted")).json() + assert {s["slug"] for s in self_hosted} == {"gitea", "hub"} + + first_party = (await client.get("/services/catalog?development_type=first_party")).json() + assert {s["slug"] for s in first_party} == {"hub"} + + level3 = (await client.get("/services/catalog?maturity_level=3")).json() + assert {s["slug"] for s in level3} == {"hub"} + + +async def test_upsert_is_idempotent_on_slug(client): + await client.post("/services/catalog", json=_svc("svc-x", "cloud_hosted", "third_party", + category="search")) + r = await client.post("/services/catalog", json=_svc("svc-x", "cloud_hosted", "third_party", + category="storage")) + assert r.status_code == 201, r.text + assert r.json()["category"] == "storage" + listed = (await client.get("/services/catalog")).json() + assert sum(1 for s in listed if s["slug"] == "svc-x") == 1 + + +async def test_invalid_dimensions_rejected(client): + r = await client.post("/services/catalog", json=_svc("bad", "on_prem", "third_party")) + assert r.status_code == 422 + r = await client.post("/services/catalog", json=_svc("bad2", "cloud_hosted", "nobody")) + assert r.status_code == 422 + + +async def test_first_party_unknown_repo_slug_404(client): + r = await client.post("/services/catalog", json=_svc( + "svc-y", "self_hosted", "first_party", + first_party={"repo_slug": "does-not-exist"}, + )) + assert r.status_code == 404 + + +async def test_get_unknown_service_404(client): + r = await client.get("/services/nope") + assert r.status_code == 404