generated from coulomb/repo-seed
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:
@@ -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
143
api/routers/services.py
Normal 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
116
api/schemas/service.py
Normal 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
|
||||
93
tests/test_services_router.py
Normal file
93
tests/test_services_router.py
Normal 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
|
||||
Reference in New Issue
Block a user