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 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 20:56:19 +02:00
parent 0192dc786f
commit 77689fbfb2
4 changed files with 354 additions and 1 deletions

View File

@@ -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)

143
api/routers/services.py Normal file
View File

@@ -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"]

116
api/schemas/service.py Normal file
View File

@@ -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

View File

@@ -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