Add OpenAPI spec generation via utoipa with /openapi.json endpoint

Integrate utoipa 5 to auto-generate an OpenAPI 3.1 spec from the existing
Axum handlers and serde types. All 16 endpoints are annotated with path
metadata, request/response schemas, security requirements, and tags. A
public /openapi.json endpoint serves the spec without requiring auth.

Includes 59 tests covering path completeness, HTTP methods, schema fields,
enum variants, security scheme, path/query parameters, request bodies,
response references, and endpoint integration.

https://claude.ai/code/session_01NfoPVx21rZUQned1f7WpXY
This commit is contained in:
Claude 2026-04-11 13:11:14 +00:00
parent e7658836a8
commit 859ec9faa8
No known key found for this signature in database
6 changed files with 1199 additions and 31 deletions

25
Cargo.lock generated
View file

@ -4603,6 +4603,7 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
"utoipa",
]
[[package]]
@ -6924,6 +6925,30 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utoipa"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
dependencies = [
"indexmap 2.13.0",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 2.0.115",
]
[[package]]
name = "uuid"
version = "1.22.0"

View file

@ -62,6 +62,7 @@ object_store = { version = "0.12.5", default-features = false, features = ["aws"
fail = "0.5"
time = { version = "0.3", features = ["formatting"] }
axum = { version = "0.8", features = ["json", "macros"] }
utoipa = { version = "5", features = ["axum_extras"] }
url = "2"
cedar-policy = "4.9"
sha2 = "0.10"

View file

@ -22,6 +22,7 @@ serde_yaml = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tower-http = { workspace = true }
utoipa = { workspace = true }
cedar-policy = { workspace = true }
futures = { workspace = true }

View file

@ -7,8 +7,25 @@ use omnigraph_compiler::SchemaMigrationStep;
use omnigraph_compiler::result::QueryResult;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use utoipa::{IntoParams, ToSchema};
#[derive(Debug, Clone, Serialize, Deserialize)]
/// Shadow enum for documenting [`LoadMode`] in the OpenAPI schema.
#[derive(ToSchema)]
#[schema(as = LoadMode)]
#[allow(dead_code)]
enum LoadModeSchema {
/// Overwrite existing data.
#[schema(rename = "overwrite")]
Overwrite,
/// Append to existing data.
#[schema(rename = "append")]
Append,
/// Merge by id key (upsert).
#[schema(rename = "merge")]
Merge,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SnapshotTableOutput {
pub table_key: String,
pub table_path: String,
@ -17,14 +34,14 @@ pub struct SnapshotTableOutput {
pub row_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SnapshotOutput {
pub branch: String,
pub manifest_version: u64,
pub tables: Vec<SnapshotTableOutput>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RunOutput {
pub run_id: String,
pub target_branch: String,
@ -39,18 +56,18 @@ pub struct RunOutput {
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RunListOutput {
pub runs: Vec<RunOutput>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BranchCreateRequest {
pub from: Option<String>,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BranchCreateOutput {
pub uri: String,
pub from: String,
@ -58,25 +75,25 @@ pub struct BranchCreateOutput {
pub actor_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BranchListOutput {
pub branches: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BranchDeleteOutput {
pub uri: String,
pub name: String,
pub actor_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BranchMergeRequest {
pub source: String,
pub target: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum BranchMergeOutcome {
AlreadyUpToDate,
@ -104,7 +121,7 @@ impl BranchMergeOutcome {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BranchMergeOutput {
pub source: String,
pub target: String,
@ -112,7 +129,7 @@ pub struct BranchMergeOutput {
pub actor_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum MergeConflictKindOutput {
DivergentInsert,
@ -152,7 +169,7 @@ impl From<MergeConflictKind> for MergeConflictKindOutput {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MergeConflictOutput {
pub table_key: String,
pub row_id: Option<String>,
@ -171,13 +188,13 @@ impl From<&MergeConflict> for MergeConflictOutput {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ReadTargetOutput {
pub branch: Option<String>,
pub snapshot: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ReadOutput {
pub query_name: String,
pub target: ReadTargetOutput,
@ -187,7 +204,7 @@ pub struct ReadOutput {
pub rows: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ChangeOutput {
pub branch: String,
pub query_name: String,
@ -196,24 +213,25 @@ pub struct ChangeOutput {
pub actor_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct IngestTableOutput {
pub table_key: String,
pub rows_loaded: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct IngestOutput {
pub uri: String,
pub branch: String,
pub base_branch: String,
pub branch_created: bool,
#[schema(value_type = LoadModeSchema)]
pub mode: LoadMode,
pub tables: Vec<IngestTableOutput>,
pub actor_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CommitOutput {
pub graph_commit_id: String,
pub manifest_branch: Option<String>,
@ -224,12 +242,12 @@ pub struct CommitOutput {
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CommitListOutput {
pub commits: Vec<CommitOutput>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ReadRequest {
pub query_source: String,
pub query_name: Option<String>,
@ -238,7 +256,7 @@ pub struct ReadRequest {
pub snapshot: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ChangeRequest {
pub query_source: String,
pub query_name: Option<String>,
@ -246,30 +264,32 @@ pub struct ChangeRequest {
pub branch: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SchemaApplyRequest {
pub schema_source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SchemaApplyOutput {
pub uri: String,
pub supported: bool,
pub applied: bool,
pub step_count: usize,
pub manifest_version: u64,
#[schema(value_type = Vec<Value>)]
pub steps: Vec<SchemaMigrationStep>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct IngestRequest {
pub branch: Option<String>,
pub from: Option<String>,
#[schema(value_type = Option<LoadModeSchema>)]
pub mode: Option<LoadMode>,
pub data: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ExportRequest {
pub branch: Option<String>,
#[serde(default)]
@ -278,17 +298,17 @@ pub struct ExportRequest {
pub table_keys: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct SnapshotQuery {
pub branch: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct CommitListQuery {
pub branch: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct HealthOutput {
pub status: String,
pub version: String,
@ -296,7 +316,7 @@ pub struct HealthOutput {
pub source_version: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
Unauthorized,
@ -307,7 +327,7 @@ pub enum ErrorCode {
Internal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ErrorOutput {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]

View file

@ -47,6 +47,50 @@ use tokio::sync::{RwLock, mpsc};
use tower_http::trace::TraceLayer;
use tracing::{error, info};
use tracing_subscriber::EnvFilter;
use utoipa::OpenApi;
use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme};
#[derive(OpenApi)]
#[openapi(
info(
title = "Omnigraph API",
description = "HTTP API for the Omnigraph graph database",
),
paths(
server_health,
server_snapshot,
server_read,
server_export,
server_change,
server_schema_apply,
server_ingest,
server_branch_list,
server_branch_create,
server_branch_delete,
server_branch_merge,
server_run_list,
server_run_show,
server_run_publish,
server_run_abort,
server_commit_list,
server_commit_show,
),
modifiers(&SecurityAddon),
)]
pub struct ApiDoc;
struct SecurityAddon;
impl utoipa::Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"bearer_token",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
);
}
}
}
const DEFAULT_REQUEST_BODY_LIMIT_BYTES: usize = 1_048_576;
const INGEST_REQUEST_BODY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
@ -386,6 +430,7 @@ pub fn build_app(state: AppState) -> Router {
Router::new()
.route("/healthz", get(server_health))
.route("/openapi.json", get(server_openapi))
.merge(protected)
.layer(DefaultBodyLimit::max(DEFAULT_REQUEST_BODY_LIMIT_BYTES))
.layer(TraceLayer::new_for_http())
@ -415,6 +460,14 @@ async fn shutdown_signal() {
info!("shutdown signal received");
}
#[utoipa::path(
get,
path = "/healthz",
tag = "health",
responses(
(status = 200, description = "Server is healthy", body = HealthOutput),
),
)]
async fn server_health() -> Json<HealthOutput> {
Json(HealthOutput {
status: "ok".to_string(),
@ -423,6 +476,10 @@ async fn server_health() -> Json<HealthOutput> {
})
}
async fn server_openapi() -> Json<utoipa::openapi::OpenApi> {
Json(ApiDoc::openapi())
}
async fn require_bearer_auth(
State(state): State<AppState>,
mut request: Request,
@ -486,6 +543,18 @@ fn authorize_request(
}
}
#[utoipa::path(
get,
path = "/snapshot",
tag = "snapshots",
params(SnapshotQuery),
responses(
(status = 200, description = "Database snapshot", body = api::SnapshotOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_snapshot(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -514,6 +583,19 @@ async fn server_snapshot(
Ok(Json(snapshot_payload(&branch, &snapshot)))
}
#[utoipa::path(
post,
path = "/read",
tag = "queries",
request_body = ReadRequest,
responses(
(status = 200, description = "Query results", body = ReadOutput),
(status = 400, description = "Bad request", body = ErrorOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_read(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -570,6 +652,19 @@ async fn server_read(
Ok(Json(api::read_output(selected_name, &target, result)))
}
#[utoipa::path(
post,
path = "/export",
tag = "queries",
request_body = ExportRequest,
responses(
(status = 200, description = "Exported data as NDJSON", content_type = "application/x-ndjson"),
(status = 400, description = "Bad request", body = ErrorOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_export(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -615,6 +710,20 @@ async fn server_export(
.into_response())
}
#[utoipa::path(
post,
path = "/change",
tag = "mutations",
request_body = ChangeRequest,
responses(
(status = 200, description = "Mutation results", body = ChangeOutput),
(status = 400, description = "Bad request", body = ErrorOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 409, description = "Merge conflict", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_change(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -659,6 +768,19 @@ async fn server_change(
}))
}
#[utoipa::path(
post,
path = "/schema/apply",
tag = "mutations",
request_body = SchemaApplyRequest,
responses(
(status = 200, description = "Schema apply results", body = SchemaApplyOutput),
(status = 400, description = "Bad request", body = ErrorOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_schema_apply(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -684,6 +806,19 @@ async fn server_schema_apply(
Ok(Json(schema_apply_output(state.uri(), result)))
}
#[utoipa::path(
post,
path = "/ingest",
tag = "mutations",
request_body = IngestRequest,
responses(
(status = 200, description = "Ingest results", body = IngestOutput),
(status = 400, description = "Bad request", body = ErrorOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_ingest(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -740,6 +875,17 @@ async fn server_ingest(
)))
}
#[utoipa::path(
get,
path = "/branches",
tag = "branches",
responses(
(status = 200, description = "List of branches", body = BranchListOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_branch_list(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -765,6 +911,20 @@ async fn server_branch_list(
Ok(Json(BranchListOutput { branches }))
}
#[utoipa::path(
post,
path = "/branches",
tag = "branches",
request_body = BranchCreateRequest,
responses(
(status = 200, description = "Branch created", body = BranchCreateOutput),
(status = 400, description = "Bad request", body = ErrorOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 409, description = "Branch already exists", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_branch_create(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -798,6 +958,21 @@ async fn server_branch_create(
}))
}
#[utoipa::path(
delete,
path = "/branches/{branch}",
tag = "branches",
params(
("branch" = String, Path, description = "Branch name to delete"),
),
responses(
(status = 200, description = "Branch deleted", body = BranchDeleteOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 404, description = "Branch not found", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_branch_delete(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -827,6 +1002,20 @@ async fn server_branch_delete(
}))
}
#[utoipa::path(
post,
path = "/branches/merge",
tag = "branches",
request_body = BranchMergeRequest,
responses(
(status = 200, description = "Branches merged", body = BranchMergeOutput),
(status = 400, description = "Bad request", body = ErrorOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 409, description = "Merge conflict", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_branch_merge(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -858,6 +1047,17 @@ async fn server_branch_merge(
}))
}
#[utoipa::path(
get,
path = "/runs",
tag = "runs",
responses(
(status = 200, description = "List of runs", body = RunListOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_run_list(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -884,6 +1084,21 @@ async fn server_run_list(
}))
}
#[utoipa::path(
get,
path = "/runs/{run_id}",
tag = "runs",
params(
("run_id" = String, Path, description = "Run identifier"),
),
responses(
(status = 200, description = "Run details", body = api::RunOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 404, description = "Run not found", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_run_show(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -911,6 +1126,21 @@ async fn server_run_show(
Ok(Json(api::run_output(&run)))
}
#[utoipa::path(
post,
path = "/runs/{run_id}/publish",
tag = "runs",
params(
("run_id" = String, Path, description = "Run identifier"),
),
responses(
(status = 200, description = "Run published", body = api::RunOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 404, description = "Run not found", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_run_publish(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -945,6 +1175,21 @@ async fn server_run_publish(
Ok(Json(api::run_output(&run)))
}
#[utoipa::path(
post,
path = "/runs/{run_id}/abort",
tag = "runs",
params(
("run_id" = String, Path, description = "Run identifier"),
),
responses(
(status = 200, description = "Run aborted", body = api::RunOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 404, description = "Run not found", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_run_abort(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -978,6 +1223,18 @@ async fn server_run_abort(
Ok(Json(api::run_output(&run)))
}
#[utoipa::path(
get,
path = "/commits",
tag = "commits",
params(CommitListQuery),
responses(
(status = 200, description = "List of commits", body = CommitListOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_commit_list(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
@ -1007,6 +1264,21 @@ async fn server_commit_list(
}))
}
#[utoipa::path(
get,
path = "/commits/{commit_id}",
tag = "commits",
params(
("commit_id" = String, Path, description = "Commit identifier"),
),
responses(
(status = 200, description = "Commit details", body = api::CommitOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 404, description = "Commit not found", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_commit_show(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,

View file

@ -0,0 +1,849 @@
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use axum::Router;
use axum::body::{Body, to_bytes};
use axum::http::{Method, Request, StatusCode};
use omnigraph::db::Omnigraph;
use omnigraph::loader::{LoadMode, load_jsonl};
use omnigraph_server::{ApiDoc, AppState, build_app};
use serde_json::Value;
use tower::ServiceExt;
use utoipa::OpenApi;
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../omnigraph/tests/fixtures")
.join(name)
}
fn repo_path(root: &Path) -> PathBuf {
root.join("openapi_test.omni")
}
async fn init_loaded_repo() -> tempfile::TempDir {
let temp = tempfile::tempdir().unwrap();
let repo = repo_path(temp.path());
fs::create_dir_all(&repo).unwrap();
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
let data = fs::read_to_string(fixture("test.jsonl")).unwrap();
Omnigraph::init(repo.to_str().unwrap(), &schema)
.await
.unwrap();
let mut db = Omnigraph::open(repo.to_str().unwrap()).await.unwrap();
load_jsonl(&mut db, &data, LoadMode::Overwrite)
.await
.unwrap();
temp
}
async fn app_for_loaded_repo() -> (tempfile::TempDir, Router) {
let temp = init_loaded_repo().await;
let repo = repo_path(temp.path());
let state = AppState::open(repo.to_string_lossy().to_string())
.await
.unwrap();
let app = build_app(state);
(temp, app)
}
async fn json_response(app: &Router, request: Request<Body>) -> (StatusCode, Value) {
let response = app.clone().oneshot(request).await.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
(status, json)
}
fn openapi_doc() -> utoipa::openapi::OpenApi {
ApiDoc::openapi()
}
fn openapi_json() -> Value {
serde_json::to_value(openapi_doc()).unwrap()
}
// ---------------------------------------------------------------------------
// Endpoint integration tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn openapi_endpoint_returns_200_with_valid_json() {
let (_temp, app) = app_for_loaded_repo().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);
assert!(json.is_object(), "response must be a JSON object");
}
#[tokio::test]
async fn openapi_endpoint_returns_openapi_31_version() {
let (_temp, app) = app_for_loaded_repo().await;
let request = Request::builder()
.method(Method::GET)
.uri("/openapi.json")
.body(Body::empty())
.unwrap();
let (_, json) = json_response(&app, request).await;
let version = json["openapi"].as_str().unwrap();
assert!(
version.starts_with("3.1"),
"expected OpenAPI 3.1.x, got {version}"
);
}
#[tokio::test]
async fn openapi_endpoint_does_not_require_auth() {
let temp = init_loaded_repo().await;
let repo = repo_path(temp.path());
let db = Omnigraph::open(repo.to_str().unwrap()).await.unwrap();
let state = AppState::new_with_bearer_token(
repo.to_string_lossy().to_string(),
db,
Some("secret-token".to_string()),
);
let app = build_app(state);
let request = Request::builder()
.method(Method::GET)
.uri("/openapi.json")
.body(Body::empty())
.unwrap();
let (status, _) = json_response(&app, request).await;
assert_eq!(status, StatusCode::OK, "/openapi.json should not require auth");
}
// ---------------------------------------------------------------------------
// Info and metadata tests
// ---------------------------------------------------------------------------
#[test]
fn openapi_info_contains_title_and_description() {
let doc = openapi_json();
let info = &doc["info"];
assert_eq!(info["title"].as_str().unwrap(), "Omnigraph API");
assert!(info["description"].as_str().unwrap().contains("Omnigraph"));
}
#[test]
fn openapi_info_contains_version() {
let doc = openapi_json();
let version = doc["info"]["version"].as_str().unwrap();
assert!(!version.is_empty(), "version must not be empty");
}
// ---------------------------------------------------------------------------
// Path coverage tests
// ---------------------------------------------------------------------------
const EXPECTED_PATHS: &[&str] = &[
"/healthz",
"/snapshot",
"/read",
"/export",
"/change",
"/schema/apply",
"/ingest",
"/branches",
"/branches/{branch}",
"/branches/merge",
"/runs",
"/runs/{run_id}",
"/runs/{run_id}/publish",
"/runs/{run_id}/abort",
"/commits",
"/commits/{commit_id}",
];
#[test]
fn openapi_contains_all_expected_paths() {
let doc = openapi_json();
let paths = doc["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_PATHS {
assert!(
path_keys.contains(expected),
"missing path: {expected}. Found: {path_keys:?}"
);
}
}
#[test]
fn openapi_has_no_unexpected_paths() {
let doc = openapi_json();
let paths = doc["paths"].as_object().expect("paths must be an object");
let expected: HashSet<&str> = EXPECTED_PATHS.iter().copied().collect();
for path in paths.keys() {
assert!(
expected.contains(path.as_str()),
"unexpected path in OpenAPI spec: {path}"
);
}
}
// ---------------------------------------------------------------------------
// HTTP method tests
// ---------------------------------------------------------------------------
#[test]
fn openapi_healthz_is_get() {
let doc = openapi_json();
assert!(doc["paths"]["/healthz"]["get"].is_object());
}
#[test]
fn openapi_read_is_post() {
let doc = openapi_json();
assert!(doc["paths"]["/read"]["post"].is_object());
}
#[test]
fn openapi_export_is_post() {
let doc = openapi_json();
assert!(doc["paths"]["/export"]["post"].is_object());
}
#[test]
fn openapi_change_is_post() {
let doc = openapi_json();
assert!(doc["paths"]["/change"]["post"].is_object());
}
#[test]
fn openapi_ingest_is_post() {
let doc = openapi_json();
assert!(doc["paths"]["/ingest"]["post"].is_object());
}
#[test]
fn openapi_branches_supports_get_and_post() {
let doc = openapi_json();
assert!(doc["paths"]["/branches"]["get"].is_object());
assert!(doc["paths"]["/branches"]["post"].is_object());
}
#[test]
fn openapi_branch_delete_is_delete() {
let doc = openapi_json();
assert!(doc["paths"]["/branches/{branch}"]["delete"].is_object());
}
#[test]
fn openapi_branch_merge_is_post() {
let doc = openapi_json();
assert!(doc["paths"]["/branches/merge"]["post"].is_object());
}
#[test]
fn openapi_runs_is_get() {
let doc = openapi_json();
assert!(doc["paths"]["/runs"]["get"].is_object());
}
#[test]
fn openapi_run_show_is_get() {
let doc = openapi_json();
assert!(doc["paths"]["/runs/{run_id}"]["get"].is_object());
}
#[test]
fn openapi_run_publish_is_post() {
let doc = openapi_json();
assert!(doc["paths"]["/runs/{run_id}/publish"]["post"].is_object());
}
#[test]
fn openapi_run_abort_is_post() {
let doc = openapi_json();
assert!(doc["paths"]["/runs/{run_id}/abort"]["post"].is_object());
}
#[test]
fn openapi_commits_is_get() {
let doc = openapi_json();
assert!(doc["paths"]["/commits"]["get"].is_object());
}
#[test]
fn openapi_commit_show_is_get() {
let doc = openapi_json();
assert!(doc["paths"]["/commits/{commit_id}"]["get"].is_object());
}
// ---------------------------------------------------------------------------
// Schema coverage tests
// ---------------------------------------------------------------------------
const EXPECTED_SCHEMAS: &[&str] = &[
"BranchCreateOutput",
"BranchCreateRequest",
"BranchDeleteOutput",
"BranchListOutput",
"BranchMergeOutcome",
"BranchMergeOutput",
"BranchMergeRequest",
"ChangeOutput",
"ChangeRequest",
"CommitListOutput",
"CommitOutput",
"ErrorCode",
"ErrorOutput",
"ExportRequest",
"HealthOutput",
"IngestOutput",
"IngestRequest",
"IngestTableOutput",
"LoadMode",
"MergeConflictKindOutput",
"MergeConflictOutput",
"ReadOutput",
"ReadRequest",
"ReadTargetOutput",
"SchemaApplyOutput",
"SchemaApplyRequest",
"RunListOutput",
"RunOutput",
"SnapshotOutput",
"SnapshotTableOutput",
];
#[test]
fn openapi_contains_all_expected_schemas() {
let doc = openapi_json();
let schemas = doc["components"]["schemas"]
.as_object()
.expect("schemas must be an object");
let schema_keys: HashSet<&str> = schemas.keys().map(|k| k.as_str()).collect();
for expected in EXPECTED_SCHEMAS {
assert!(
schema_keys.contains(expected),
"missing schema: {expected}. Found: {schema_keys:?}"
);
}
}
// ---------------------------------------------------------------------------
// Schema field validation tests
// ---------------------------------------------------------------------------
#[test]
fn health_output_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["HealthOutput"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("status"));
assert!(props.contains_key("version"));
assert!(props.contains_key("source_version"));
}
#[test]
fn read_request_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["ReadRequest"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("query_source"));
assert!(props.contains_key("query_name"));
assert!(props.contains_key("params"));
assert!(props.contains_key("branch"));
assert!(props.contains_key("snapshot"));
}
#[test]
fn read_request_query_source_is_required() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["ReadRequest"];
let required: Vec<&str> = schema["required"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(required.contains(&"query_source"));
}
#[test]
fn read_output_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["ReadOutput"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("query_name"));
assert!(props.contains_key("target"));
assert!(props.contains_key("row_count"));
assert!(props.contains_key("rows"));
}
#[test]
fn change_request_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["ChangeRequest"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("query_source"));
assert!(props.contains_key("query_name"));
assert!(props.contains_key("params"));
assert!(props.contains_key("branch"));
}
#[test]
fn change_output_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["ChangeOutput"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("branch"));
assert!(props.contains_key("query_name"));
assert!(props.contains_key("affected_nodes"));
assert!(props.contains_key("affected_edges"));
}
#[test]
fn ingest_request_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["IngestRequest"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("branch"));
assert!(props.contains_key("from"));
assert!(props.contains_key("mode"));
assert!(props.contains_key("data"));
}
#[test]
fn ingest_output_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["IngestOutput"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("uri"));
assert!(props.contains_key("branch"));
assert!(props.contains_key("base_branch"));
assert!(props.contains_key("branch_created"));
assert!(props.contains_key("mode"));
assert!(props.contains_key("tables"));
}
#[test]
fn export_request_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["ExportRequest"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("branch"));
assert!(props.contains_key("type_names"));
assert!(props.contains_key("table_keys"));
}
#[test]
fn branch_create_request_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["BranchCreateRequest"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("from"));
assert!(props.contains_key("name"));
}
#[test]
fn branch_create_request_name_is_required() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["BranchCreateRequest"];
let required: Vec<&str> = schema["required"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(required.contains(&"name"));
}
#[test]
fn branch_merge_request_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["BranchMergeRequest"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("source"));
assert!(props.contains_key("target"));
}
#[test]
fn error_output_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["ErrorOutput"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("error"));
assert!(props.contains_key("code"));
assert!(props.contains_key("merge_conflicts"));
}
#[test]
fn run_output_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["RunOutput"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("run_id"));
assert!(props.contains_key("target_branch"));
assert!(props.contains_key("run_branch"));
assert!(props.contains_key("status"));
assert!(props.contains_key("created_at"));
assert!(props.contains_key("updated_at"));
}
#[test]
fn commit_output_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["CommitOutput"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("graph_commit_id"));
assert!(props.contains_key("manifest_version"));
assert!(props.contains_key("parent_commit_id"));
assert!(props.contains_key("actor_id"));
assert!(props.contains_key("created_at"));
}
#[test]
fn snapshot_output_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["SnapshotOutput"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("branch"));
assert!(props.contains_key("manifest_version"));
assert!(props.contains_key("tables"));
}
#[test]
fn snapshot_table_output_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["SnapshotTableOutput"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("table_key"));
assert!(props.contains_key("table_path"));
assert!(props.contains_key("table_version"));
assert!(props.contains_key("row_count"));
}
// ---------------------------------------------------------------------------
// Enum schema tests
// ---------------------------------------------------------------------------
#[test]
fn load_mode_schema_has_three_variants() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["LoadMode"];
let variants = schema["enum"].as_array().unwrap();
assert_eq!(variants.len(), 3);
let values: HashSet<&str> = variants.iter().map(|v| v.as_str().unwrap()).collect();
assert!(values.contains("overwrite"));
assert!(values.contains("append"));
assert!(values.contains("merge"));
}
#[test]
fn branch_merge_outcome_schema_has_three_variants() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["BranchMergeOutcome"];
let variants = schema["enum"].as_array().unwrap();
assert_eq!(variants.len(), 3);
let values: HashSet<&str> = variants.iter().map(|v| v.as_str().unwrap()).collect();
assert!(values.contains("already_up_to_date"));
assert!(values.contains("fast_forward"));
assert!(values.contains("merged"));
}
#[test]
fn error_code_schema_has_expected_variants() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["ErrorCode"];
let variants = schema["enum"].as_array().unwrap();
let values: HashSet<&str> = variants.iter().map(|v| v.as_str().unwrap()).collect();
assert!(values.contains("unauthorized"));
assert!(values.contains("forbidden"));
assert!(values.contains("bad_request"));
assert!(values.contains("not_found"));
assert!(values.contains("conflict"));
assert!(values.contains("internal"));
}
#[test]
fn merge_conflict_kind_output_schema_has_expected_variants() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["MergeConflictKindOutput"];
let variants = schema["enum"].as_array().unwrap();
let values: HashSet<&str> = variants.iter().map(|v| v.as_str().unwrap()).collect();
assert!(values.contains("divergent_insert"));
assert!(values.contains("divergent_update"));
assert!(values.contains("delete_vs_update"));
assert!(values.contains("orphan_edge"));
assert!(values.contains("unique_violation"));
assert!(values.contains("cardinality_violation"));
assert!(values.contains("value_constraint_violation"));
}
// ---------------------------------------------------------------------------
// Security scheme tests
// ---------------------------------------------------------------------------
#[test]
fn openapi_defines_bearer_token_security_scheme() {
let doc = openapi_json();
let scheme = &doc["components"]["securitySchemes"]["bearer_token"];
assert_eq!(scheme["type"].as_str().unwrap(), "http");
assert_eq!(scheme["scheme"].as_str().unwrap(), "bearer");
}
#[test]
fn protected_endpoints_reference_bearer_token_security() {
let doc = openapi_json();
let protected_paths = [
("/read", "post"),
("/change", "post"),
("/schema/apply", "post"),
("/ingest", "post"),
("/export", "post"),
("/snapshot", "get"),
("/branches", "get"),
("/branches", "post"),
("/branches/{branch}", "delete"),
("/branches/merge", "post"),
("/runs", "get"),
("/runs/{run_id}", "get"),
("/runs/{run_id}/publish", "post"),
("/runs/{run_id}/abort", "post"),
("/commits", "get"),
("/commits/{commit_id}", "get"),
];
for (path, method) in protected_paths {
let operation = &doc["paths"][path][method];
let security = operation["security"]
.as_array()
.unwrap_or_else(|| panic!("no security on {method} {path}"));
let has_bearer = security
.iter()
.any(|s| s.as_object().unwrap().contains_key("bearer_token"));
assert!(has_bearer, "{method} {path} missing bearer_token security");
}
}
#[test]
fn healthz_does_not_require_security() {
let doc = openapi_json();
let healthz = &doc["paths"]["/healthz"]["get"];
assert!(
healthz.get("security").is_none() || healthz["security"].is_null(),
"/healthz should not have security requirements"
);
}
// ---------------------------------------------------------------------------
// Path parameter tests
// ---------------------------------------------------------------------------
#[test]
fn branch_delete_has_branch_path_parameter() {
let doc = openapi_json();
let params = doc["paths"]["/branches/{branch}"]["delete"]["parameters"]
.as_array()
.unwrap();
let has_branch = params.iter().any(|p| {
p["name"].as_str() == Some("branch") && p["in"].as_str() == Some("path")
});
assert!(has_branch, "DELETE /branches/{{branch}} must have 'branch' path parameter");
}
#[test]
fn run_show_has_run_id_path_parameter() {
let doc = openapi_json();
let params = doc["paths"]["/runs/{run_id}"]["get"]["parameters"]
.as_array()
.unwrap();
let has_run_id = params.iter().any(|p| {
p["name"].as_str() == Some("run_id") && p["in"].as_str() == Some("path")
});
assert!(has_run_id, "GET /runs/{{run_id}} must have 'run_id' path parameter");
}
#[test]
fn commit_show_has_commit_id_path_parameter() {
let doc = openapi_json();
let params = doc["paths"]["/commits/{commit_id}"]["get"]["parameters"]
.as_array()
.unwrap();
let has_commit_id = params.iter().any(|p| {
p["name"].as_str() == Some("commit_id") && p["in"].as_str() == Some("path")
});
assert!(has_commit_id, "GET /commits/{{commit_id}} must have 'commit_id' path parameter");
}
#[test]
fn snapshot_has_branch_query_parameter() {
let doc = openapi_json();
let params = doc["paths"]["/snapshot"]["get"]["parameters"]
.as_array()
.unwrap();
let has_branch = params.iter().any(|p| {
p["name"].as_str() == Some("branch") && p["in"].as_str() == Some("query")
});
assert!(has_branch, "GET /snapshot must have 'branch' query parameter");
}
#[test]
fn commits_has_branch_query_parameter() {
let doc = openapi_json();
let params = doc["paths"]["/commits"]["get"]["parameters"]
.as_array()
.unwrap();
let has_branch = params.iter().any(|p| {
p["name"].as_str() == Some("branch") && p["in"].as_str() == Some("query")
});
assert!(has_branch, "GET /commits must have 'branch' query parameter");
}
// ---------------------------------------------------------------------------
// Tag tests
// ---------------------------------------------------------------------------
#[test]
fn openapi_operations_have_tags() {
let doc = openapi_json();
let paths = doc["paths"].as_object().unwrap();
for (path, methods) in paths {
let methods = methods.as_object().unwrap();
for (method, operation) in methods {
let tags = operation["tags"].as_array();
assert!(
tags.is_some_and(|t| !t.is_empty()),
"{method} {path} should have at least one tag"
);
}
}
}
// ---------------------------------------------------------------------------
// Response schema reference tests
// ---------------------------------------------------------------------------
#[test]
fn read_endpoint_200_references_read_output_schema() {
let doc = openapi_json();
let content = &doc["paths"]["/read"]["post"]["responses"]["200"]["content"];
let schema = &content["application/json"]["schema"];
let ref_path = schema["$ref"].as_str().unwrap();
assert!(
ref_path.contains("ReadOutput"),
"POST /read 200 should reference ReadOutput, got {ref_path}"
);
}
#[test]
fn change_endpoint_200_references_change_output_schema() {
let doc = openapi_json();
let content = &doc["paths"]["/change"]["post"]["responses"]["200"]["content"];
let schema = &content["application/json"]["schema"];
let ref_path = schema["$ref"].as_str().unwrap();
assert!(
ref_path.contains("ChangeOutput"),
"POST /change 200 should reference ChangeOutput, got {ref_path}"
);
}
#[test]
fn healthz_200_references_health_output_schema() {
let doc = openapi_json();
let content = &doc["paths"]["/healthz"]["get"]["responses"]["200"]["content"];
let schema = &content["application/json"]["schema"];
let ref_path = schema["$ref"].as_str().unwrap();
assert!(
ref_path.contains("HealthOutput"),
"GET /healthz 200 should reference HealthOutput, got {ref_path}"
);
}
#[test]
fn error_responses_reference_error_output_schema() {
let doc = openapi_json();
let paths_with_errors = [
("/read", "post", "400"),
("/read", "post", "401"),
("/change", "post", "400"),
("/change", "post", "409"),
("/branches", "post", "409"),
];
for (path, method, status) in paths_with_errors {
let content =
&doc["paths"][path][method]["responses"][status]["content"];
let schema = &content["application/json"]["schema"];
let ref_path = schema["$ref"].as_str().unwrap();
assert!(
ref_path.contains("ErrorOutput"),
"{method} {path} {status} should reference ErrorOutput, got {ref_path}"
);
}
}
// ---------------------------------------------------------------------------
// Request body reference tests
// ---------------------------------------------------------------------------
#[test]
fn post_endpoints_have_request_body() {
let doc = openapi_json();
let post_paths = [
("/read", "ReadRequest"),
("/change", "ChangeRequest"),
("/schema/apply", "SchemaApplyRequest"),
("/ingest", "IngestRequest"),
("/export", "ExportRequest"),
("/branches", "BranchCreateRequest"),
("/branches/merge", "BranchMergeRequest"),
];
for (path, expected_schema) in post_paths {
let request_body = &doc["paths"][path]["post"]["requestBody"];
assert!(
request_body.is_object(),
"POST {path} should have a requestBody"
);
let schema = &request_body["content"]["application/json"]["schema"];
let ref_path = schema["$ref"].as_str().unwrap();
assert!(
ref_path.contains(expected_schema),
"POST {path} requestBody should reference {expected_schema}, got {ref_path}"
);
}
}
// ---------------------------------------------------------------------------
// Serialization round-trip test
// ---------------------------------------------------------------------------
#[test]
fn openapi_spec_round_trips_through_json() {
let doc = openapi_doc();
let json_string = serde_json::to_string_pretty(&doc).unwrap();
let parsed: Value = serde_json::from_str(&json_string).unwrap();
assert!(parsed["openapi"].is_string());
assert!(parsed["paths"].is_object());
assert!(parsed["components"]["schemas"].is_object());
}
// ---------------------------------------------------------------------------
// Endpoint live round-trip: the doc served matches the static generation
// ---------------------------------------------------------------------------
#[tokio::test]
async fn openapi_endpoint_matches_static_generation() {
let (_temp, app) = app_for_loaded_repo().await;
let request = Request::builder()
.method(Method::GET)
.uri("/openapi.json")
.body(Body::empty())
.unwrap();
let (_, served) = json_response(&app, request).await;
let static_doc = openapi_json();
assert_eq!(served, static_doc, "served spec must match static generation");
}