mr-668: OpenAPI multi-mode cluster filter (PR 4b/10)

PR 4b of the MR-668 multi-graph server work. In multi mode, the served
`/openapi.json` reports cluster routes (`/graphs/{graph_id}/...`) instead
of the legacy flat protected paths — matching what `build_app` actually
mounts (PR 4a's `Router::nest`). Single mode is unchanged.

Implementation:
- New `server_openapi` branch: when `state.mode()` is `Multi`, call
  `nest_paths_under_cluster_prefix(&mut doc)` after `ApiDoc::openapi()`.
- The rewrite consumes `doc.paths.paths`, then for every path-item:
  - If the path is in `ALWAYS_FLAT_PATHS` (`/healthz` for now), keep
    it flat.
  - Otherwise, prefix every operation_id with `cluster_` and reinsert
    the item at `/graphs/{graph_id}<original_path>`.
- Single mode hits no extra work — the path map is untouched.
- The static `ApiDoc::openapi()` still emits the flat surface, so
  in-process callers (the existing `openapi_json()` helper in tests)
  see the unmodified spec.

Why cluster_ prefix on operation IDs: OpenAPI specs require unique
operation_ids across the document. With both flat (single-mode) and
cluster (multi-mode) surfaces ever co-existing in a generated SDK,
the prefix prevents collision. The current served doc only carries
one surface, so the prefix is forward-compat with potential future
dual-surface generation.

Tests: 6 new in `tests/openapi.rs`, all via the `/openapi.json` route
(not the static `ApiDoc::openapi()` helper):
- `multi_mode_openapi_lists_cluster_paths` — every protected path
  appears as a cluster variant.
- `multi_mode_openapi_drops_flat_protected_paths` — flat protected
  paths are absent.
- `multi_mode_openapi_keeps_healthz_flat` — `/healthz` survives.
- `multi_mode_openapi_prefixes_operation_ids_with_cluster` — every
  cluster operation_id starts with `cluster_`.
- `multi_mode_operation_ids_are_unique` — no operation_id collisions.
- `single_mode_openapi_unchanged_by_cluster_filter` — single mode
  still emits the legacy flat surface (regression).

New test helper `app_for_multi_mode(graph_ids)` exercises the new
`AppState::new_multi` constructor from PR 4a — first user of multi-mode
construction outside of unit tests.

Result: 66 openapi tests + 57 server integration tests + 74 lib tests
= 197 green. No regression in the existing OpenAPI drift check
(`openapi_spec_is_up_to_date` still validates the static flat surface
matches the committed openapi.json).

LOC: +67 in lib.rs (rewrite logic), +219 in tests/openapi.rs (test
suite + helper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ragnor Comerford 2026-05-25 19:59:02 +02:00
parent 57f5f191ab
commit ecf01ef3fe
No known key found for this signature in database
2 changed files with 286 additions and 0 deletions

View file

@ -889,9 +889,76 @@ async fn server_openapi(State(state): State<AppState>) -> Json<utoipa::openapi::
if !state.requires_bearer_auth() {
strip_security(&mut doc);
}
// MR-668 PR 4b: in multi mode, the protected routes live under
// `/graphs/{graph_id}/...`. Rewrite the doc so the spec matches
// the routes the router actually serves. Public paths (`/healthz`)
// stay flat in both modes.
if matches!(state.mode(), ServerMode::Multi { .. }) {
nest_paths_under_cluster_prefix(&mut doc);
}
Json(doc)
}
/// Path prefix used to namespace per-graph routes in multi mode.
/// Kept in sync with the `Router::nest(...)` invocation in `build_app`.
const CLUSTER_PATH_PREFIX: &str = "/graphs/{graph_id}";
/// Operation-id prefix applied to every cloned cluster operation.
/// Decision 7 in the implementation plan — keeps operation IDs unique
/// across the spec when both flat and nested variants ever appear in
/// the same generation pass.
const CLUSTER_OPERATION_ID_PREFIX: &str = "cluster_";
/// Paths that stay flat in every server mode (public, no per-graph
/// dependency). Update this list when adding new always-public endpoints.
const ALWAYS_FLAT_PATHS: &[&str] = &["/healthz"];
/// In multi-mode `server_openapi`, every protected path-item is
/// reattached under the cluster prefix. Operation IDs gain the
/// `cluster_` prefix so SDK generators don't collide if/when both
/// surfaces are merged. The `{graph_id}` URL placeholder is left
/// implicit in the path; consuming clients see it as a standard
/// OpenAPI path parameter.
///
/// Removing the flat protected paths matches the runtime router —
/// in multi mode, requests to `/snapshot` etc. return 404, so the
/// spec must agree.
fn nest_paths_under_cluster_prefix(doc: &mut utoipa::openapi::OpenApi) {
let original = std::mem::take(&mut doc.paths.paths);
let mut rewritten = std::collections::BTreeMap::new();
for (path, mut item) in original {
if ALWAYS_FLAT_PATHS.contains(&path.as_str()) {
rewritten.insert(path, item);
continue;
}
rename_operation_ids(&mut item, CLUSTER_OPERATION_ID_PREFIX);
let new_path = format!("{CLUSTER_PATH_PREFIX}{path}");
rewritten.insert(new_path, item);
}
doc.paths.paths = rewritten;
}
/// Prefix every operation_id in this PathItem with `prefix`.
fn rename_operation_ids(item: &mut utoipa::openapi::PathItem, prefix: &str) {
for op in [
item.get.as_mut(),
item.post.as_mut(),
item.put.as_mut(),
item.delete.as_mut(),
item.options.as_mut(),
item.head.as_mut(),
item.patch.as_mut(),
item.trace.as_mut(),
]
.into_iter()
.flatten()
{
if let Some(id) = op.operation_id.as_deref() {
op.operation_id = Some(format!("{prefix}{id}"));
}
}
}
fn strip_security(doc: &mut utoipa::openapi::OpenApi) {
if let Some(components) = doc.components.as_mut() {
components.security_schemes.clear();

View file

@ -957,3 +957,222 @@ fn openapi_spec_is_up_to_date() {
"openapi.json is out of date. Run: OMNIGRAPH_UPDATE_OPENAPI=1 cargo test -p omnigraph-server --test openapi openapi_spec_is_up_to_date"
);
}
// ---------------------------------------------------------------------------
// MR-668 PR 4b — multi-mode OpenAPI cluster filter
// ---------------------------------------------------------------------------
//
// In multi-graph mode, `/openapi.json` reports cluster routes
// (`/graphs/{graph_id}/...`) instead of the legacy flat routes. The
// only flat path that survives is `/healthz`. Operation IDs gain a
// `cluster_` prefix so SDK generators have stable, unique ids.
//
// These tests exercise the request-time `server_openapi` handler via
// `oneshot`, not the static `ApiDoc::openapi()` — the rewrite happens
// only on the served document.
const EXPECTED_CLUSTER_PATHS: &[&str] = &[
"/graphs/{graph_id}/snapshot",
"/graphs/{graph_id}/read",
"/graphs/{graph_id}/export",
"/graphs/{graph_id}/change",
"/graphs/{graph_id}/schema",
"/graphs/{graph_id}/schema/apply",
"/graphs/{graph_id}/ingest",
"/graphs/{graph_id}/branches",
"/graphs/{graph_id}/branches/{branch}",
"/graphs/{graph_id}/branches/merge",
"/graphs/{graph_id}/commits",
"/graphs/{graph_id}/commits/{commit_id}",
];
async fn app_for_multi_mode(graph_ids: &[&str]) -> (Vec<tempfile::TempDir>, Router) {
use std::sync::Arc;
use omnigraph_server::{GraphHandle, GraphId, GraphKey};
let mut dirs = Vec::with_capacity(graph_ids.len());
let mut handles = Vec::with_capacity(graph_ids.len());
for id in graph_ids {
let dir = tempfile::tempdir().unwrap();
let graph_uri = dir.path().join(id).to_str().unwrap().to_string();
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
let engine = Omnigraph::init(&graph_uri, &schema).await.unwrap();
handles.push(Arc::new(GraphHandle {
key: GraphKey::cluster(GraphId::try_from(*id).unwrap()),
uri: graph_uri,
engine: Arc::new(engine),
policy: None,
}));
dirs.push(dir);
}
let workload = omnigraph_server::workload::WorkloadController::from_env();
let state = AppState::new_multi(handles, Vec::new(), None, workload, None).unwrap();
let app = build_app(state);
(dirs, app)
}
#[tokio::test]
async fn multi_mode_openapi_lists_cluster_paths() {
let (_dirs, app) = app_for_multi_mode(&["alpha"]).await;
let request = Request::builder()
.method(Method::GET)
.uri("/openapi.json")
.body(Body::empty())
.unwrap();
let (status, json) = json_response(&app, request).await;
assert_eq!(status, StatusCode::OK);
let paths = json["paths"].as_object().expect("paths must be an object");
let path_keys: HashSet<&str> = paths.keys().map(|k| k.as_str()).collect();
for expected in EXPECTED_CLUSTER_PATHS {
assert!(
path_keys.contains(expected),
"missing cluster path in multi-mode spec: {expected}. \
Found: {path_keys:?}"
);
}
}
#[tokio::test]
async fn multi_mode_openapi_drops_flat_protected_paths() {
let (_dirs, app) = app_for_multi_mode(&["alpha"]).await;
let request = Request::builder()
.method(Method::GET)
.uri("/openapi.json")
.body(Body::empty())
.unwrap();
let (_, json) = json_response(&app, request).await;
let paths = json["paths"].as_object().unwrap();
// None of the legacy flat protected paths should appear in multi mode.
let flat_protected = [
"/snapshot",
"/read",
"/export",
"/change",
"/schema",
"/schema/apply",
"/ingest",
"/branches",
"/branches/{branch}",
"/branches/merge",
"/commits",
"/commits/{commit_id}",
];
for flat in flat_protected {
assert!(
!paths.contains_key(flat),
"flat path {flat} must not appear in multi-mode spec; \
cluster routes are the only protected surface"
);
}
}
#[tokio::test]
async fn multi_mode_openapi_keeps_healthz_flat() {
let (_dirs, app) = app_for_multi_mode(&["alpha"]).await;
let request = Request::builder()
.method(Method::GET)
.uri("/openapi.json")
.body(Body::empty())
.unwrap();
let (_, json) = json_response(&app, request).await;
let paths = json["paths"].as_object().unwrap();
assert!(
paths.contains_key("/healthz"),
"/healthz must remain flat in multi mode"
);
assert!(
!paths.contains_key("/graphs/{graph_id}/healthz"),
"/healthz must NOT be cluster-prefixed"
);
}
#[tokio::test]
async fn multi_mode_openapi_prefixes_operation_ids_with_cluster() {
let (_dirs, app) = app_for_multi_mode(&["alpha"]).await;
let request = Request::builder()
.method(Method::GET)
.uri("/openapi.json")
.body(Body::empty())
.unwrap();
let (_, json) = json_response(&app, request).await;
// Every cluster path operation must have a `cluster_` operation_id.
let paths = json["paths"].as_object().unwrap();
let mut checked = 0;
for (path, item) in paths {
if path == "/healthz" {
continue;
}
for method in ["get", "post", "put", "delete", "patch"] {
if let Some(op) = item.get(method).filter(|v| v.is_object()) {
if let Some(id) = op["operationId"].as_str() {
assert!(
id.starts_with("cluster_"),
"operation_id at {path}.{method} must start with `cluster_`, got `{id}`"
);
checked += 1;
}
}
}
}
assert!(
checked >= EXPECTED_CLUSTER_PATHS.len(),
"expected at least {} cluster operation_ids; checked {checked}",
EXPECTED_CLUSTER_PATHS.len()
);
}
#[tokio::test]
async fn multi_mode_operation_ids_are_unique() {
// Sanity check: the cluster_ prefix prevents collision with flat ids
// (which don't appear in multi mode, but the contract is "unique
// across the spec"). Verify every operation_id in the multi-mode
// spec is unique.
let (_dirs, app) = app_for_multi_mode(&["alpha"]).await;
let request = Request::builder()
.method(Method::GET)
.uri("/openapi.json")
.body(Body::empty())
.unwrap();
let (_, json) = json_response(&app, request).await;
let paths = json["paths"].as_object().unwrap();
let mut seen_ids: HashSet<String> = HashSet::new();
for (_, item) in paths {
for method in ["get", "post", "put", "delete", "patch"] {
if let Some(op) = item.get(method).filter(|v| v.is_object()) {
if let Some(id) = op["operationId"].as_str() {
assert!(
seen_ids.insert(id.to_string()),
"duplicate operation_id `{id}` in multi-mode spec"
);
}
}
}
}
}
#[tokio::test]
async fn single_mode_openapi_unchanged_by_cluster_filter() {
// Regression: single mode still emits the legacy flat surface.
let (_temp, app) = app_for_loaded_graph().await;
let request = Request::builder()
.method(Method::GET)
.uri("/openapi.json")
.body(Body::empty())
.unwrap();
let (_, json) = json_response(&app, request).await;
let paths = json["paths"].as_object().unwrap();
let path_keys: HashSet<&str> = paths.keys().map(|k| k.as_str()).collect();
for expected in EXPECTED_PATHS {
assert!(
path_keys.contains(expected),
"single mode must still emit flat path: {expected}"
);
}
for cluster in EXPECTED_CLUSTER_PATHS {
assert!(
!path_keys.contains(cluster),
"single mode must NOT emit cluster path: {cluster}"
);
}
}