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.database import engine
|
||||||
from api.events import shutdown_publisher
|
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 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 token_events
|
||||||
from api.routers import interface_changes
|
from api.routers import interface_changes
|
||||||
from api.routers import flows
|
from api.routers import flows
|
||||||
@@ -120,6 +120,7 @@ app.include_router(sbom.router)
|
|||||||
app.include_router(messages.router)
|
app.include_router(messages.router)
|
||||||
app.include_router(capability_requests.router)
|
app.include_router(capability_requests.router)
|
||||||
app.include_router(tpsc.router)
|
app.include_router(tpsc.router)
|
||||||
|
app.include_router(services.router)
|
||||||
app.include_router(token_events.router)
|
app.include_router(token_events.router)
|
||||||
app.include_router(interface_changes.router)
|
app.include_router(interface_changes.router)
|
||||||
app.include_router(flows.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