feat(api): topology endpoints (catalog / classify / build)
Stateless, auth-gated routes backing the topology builder UI:
- GET /api/topologies — catalog of the 12 kinds + descriptions + role mix
- POST /api/topologies/classify — infer a kind + metrics from a posted graph
- POST /api/topologies/build — build a canonical graph from {kind, roles}
Add Serialize to cm-topology Classification/GraphMetrics; add ApiError::BadRequest
(400) for invalid build input; add cm-topology dep. 3 integration tests
(catalog/build/auth) green against Postgres; offline build + clippy clean.
No DB or provider yet — running/comparing topologies is a later provider-backed
endpoint. Server redeploy will batch with the ReactFlow UI that consumes these.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b0c122b88d
commit
fa7e5dec5f
Generated
+1
@@ -496,6 +496,7 @@ dependencies = [
|
|||||||
"cm-scheduler",
|
"cm-scheduler",
|
||||||
"cm-secrets",
|
"cm-secrets",
|
||||||
"cm-testkit",
|
"cm-testkit",
|
||||||
|
"cm-topology",
|
||||||
"eventsource-stream",
|
"eventsource-stream",
|
||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ cm-runtime = { path = "../cm-runtime" }
|
|||||||
cm-safety = { path = "../cm-safety" }
|
cm-safety = { path = "../cm-safety" }
|
||||||
cm-scheduler = { path = "../cm-scheduler" }
|
cm-scheduler = { path = "../cm-scheduler" }
|
||||||
cm-secrets = { path = "../cm-secrets" }
|
cm-secrets = { path = "../cm-secrets" }
|
||||||
|
cm-topology = { path = "../cm-topology" }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tower-http = { version = "0.6", features = ["trace"] }
|
tower-http = { version = "0.6", features = ["trace"] }
|
||||||
time = { workspace = true }
|
time = { workspace = true }
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use serde_json::json;
|
|||||||
/// server-side, never echoed to clients.
|
/// server-side, never echoed to clients.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ApiError {
|
pub enum ApiError {
|
||||||
|
#[error("bad request")]
|
||||||
|
BadRequest,
|
||||||
#[error("unauthorized")]
|
#[error("unauthorized")]
|
||||||
Unauthorized,
|
Unauthorized,
|
||||||
#[error("forbidden")]
|
#[error("forbidden")]
|
||||||
@@ -48,6 +50,7 @@ impl From<cm_auth::AuthError> for ApiError {
|
|||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let status = match self {
|
let status = match self {
|
||||||
|
ApiError::BadRequest => StatusCode::BAD_REQUEST,
|
||||||
ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
|
ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||||
ApiError::Forbidden => StatusCode::FORBIDDEN,
|
ApiError::Forbidden => StatusCode::FORBIDDEN,
|
||||||
ApiError::NotFound => StatusCode::NOT_FOUND,
|
ApiError::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
|||||||
@@ -135,6 +135,9 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/billing/config", get(routes::billing::billing_config))
|
.route("/api/billing/config", get(routes::billing::billing_config))
|
||||||
.route("/api/billing/stripe", post(routes::billing::stripe_webhook))
|
.route("/api/billing/stripe", post(routes::billing::stripe_webhook))
|
||||||
.route("/api/team/permissions", get(routes::team::permissions))
|
.route("/api/team/permissions", get(routes::team::permissions))
|
||||||
|
.route("/api/topologies", get(routes::topology::catalog))
|
||||||
|
.route("/api/topologies/classify", post(routes::topology::classify_graph))
|
||||||
|
.route("/api/topologies/build", post(routes::topology::build_graph))
|
||||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ pub mod sessions;
|
|||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod slack;
|
pub mod slack;
|
||||||
pub mod team;
|
pub mod team;
|
||||||
|
pub mod topology;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
//! Topology endpoints: the catalog, structural classification, and building a
|
||||||
|
//! canonical graph from a kind + roles. Stateless (no DB, no provider) — these
|
||||||
|
//! back the topology builder UI. Running/comparing topologies is a later,
|
||||||
|
//! provider-backed endpoint.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use cm_topology::{build, classify, heuristics, Classification, TopologyGraph, TopologyKind};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{ApiError, Authed};
|
||||||
|
|
||||||
|
/// A role and its suggested share of the team.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RoleWeight {
|
||||||
|
pub role: String,
|
||||||
|
pub weight: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One supported topology kind, with its description and default role mix.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CatalogEntry {
|
||||||
|
pub kind: TopologyKind,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub role_distribution: Vec<RoleWeight>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/topologies` — the catalog of supported topology kinds.
|
||||||
|
pub async fn catalog(_auth: Authed) -> Json<Vec<CatalogEntry>> {
|
||||||
|
let entries = TopologyKind::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|&kind| {
|
||||||
|
let h = heuristics(kind);
|
||||||
|
CatalogEntry {
|
||||||
|
kind,
|
||||||
|
name: kind.as_str().to_string(),
|
||||||
|
description: kind.description().to_string(),
|
||||||
|
role_distribution: h
|
||||||
|
.role_distribution
|
||||||
|
.iter()
|
||||||
|
.map(|(role, weight)| RoleWeight {
|
||||||
|
role: (*role).to_string(),
|
||||||
|
weight: *weight,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Json(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/topologies/classify` — infer a topology kind from a graph.
|
||||||
|
pub async fn classify_graph(_auth: Authed, Json(graph): Json<TopologyGraph>) -> Json<Classification> {
|
||||||
|
Json(classify(&graph))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for building a canonical topology.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct BuildRequest {
|
||||||
|
pub kind: TopologyKind,
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/topologies/build` — build a canonical graph from a kind + roles.
|
||||||
|
pub async fn build_graph(
|
||||||
|
_auth: Authed,
|
||||||
|
Json(req): Json<BuildRequest>,
|
||||||
|
) -> Result<Json<TopologyGraph>, ApiError> {
|
||||||
|
let roles: Vec<&str> = req.roles.iter().map(String::as_str).collect();
|
||||||
|
let graph = build(req.kind, &roles).map_err(|_| ApiError::BadRequest)?;
|
||||||
|
Ok(Json(graph))
|
||||||
|
}
|
||||||
@@ -278,3 +278,73 @@ async fn team_members_lists_workspace_users() {
|
|||||||
assert_eq!(list[0]["role"], "owner");
|
assert_eq!(list[0]["role"], "owner");
|
||||||
assert_eq!(list[0]["display_name"], "Owner");
|
assert_eq!(list[0]["display_name"], "Owner");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn topology_catalog_lists_all_kinds() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
seed(&pool).await;
|
||||||
|
let server = serve(pool).await;
|
||||||
|
let token = login(&server).await;
|
||||||
|
|
||||||
|
let catalog: Value = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/topologies", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let kinds = catalog.as_array().unwrap();
|
||||||
|
assert_eq!(kinds.len(), 12);
|
||||||
|
assert!(kinds.iter().any(|k| k["kind"] == "hierarchical"));
|
||||||
|
assert!(!kinds[0]["role_distribution"].as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn topology_build_returns_a_canonical_graph() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
seed(&pool).await;
|
||||||
|
let server = serve(pool).await;
|
||||||
|
let token = login(&server).await;
|
||||||
|
|
||||||
|
let graph: Value = server
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/topologies/build", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.json(&json!({ "kind": "pipeline", "roles": ["a", "b", "c"] }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(graph["kind"], "pipeline");
|
||||||
|
assert_eq!(graph["nodes"].as_array().unwrap().len(), 3);
|
||||||
|
assert_eq!(graph["edges"].as_array().unwrap().len(), 2);
|
||||||
|
|
||||||
|
// build rejects empty roles with 400.
|
||||||
|
let bad = server
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/topologies/build", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.json(&json!({ "kind": "pipeline", "roles": [] }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bad.status(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn topology_endpoints_require_auth() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let server = serve(pool).await;
|
||||||
|
let res = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/topologies", server.base))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), 401);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,12 +8,14 @@
|
|||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::graph::TopologyGraph;
|
use crate::graph::TopologyGraph;
|
||||||
use crate::kind::TopologyKind;
|
use crate::kind::TopologyKind;
|
||||||
|
|
||||||
/// Objective structural metrics for a topology graph (treated undirected for
|
/// Objective structural metrics for a topology graph (treated undirected for
|
||||||
/// connectivity, directed for hierarchy).
|
/// connectivity, directed for hierarchy).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
|
||||||
pub struct GraphMetrics {
|
pub struct GraphMetrics {
|
||||||
/// Number of nodes.
|
/// Number of nodes.
|
||||||
pub order: usize,
|
pub order: usize,
|
||||||
@@ -38,7 +40,7 @@ pub struct GraphMetrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The result of [`classify`].
|
/// The result of [`classify`].
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
|
||||||
pub struct Classification {
|
pub struct Classification {
|
||||||
/// Best-matching topology kind.
|
/// Best-matching topology kind.
|
||||||
pub primary: TopologyKind,
|
pub primary: TopologyKind,
|
||||||
|
|||||||
Reference in New Issue
Block a user