diff --git a/Cargo.lock b/Cargo.lock index 6ba77b5..7b9d36f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 91861ce..34e2062 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/omnigraph-server/Cargo.toml b/crates/omnigraph-server/Cargo.toml index d649a89..c4a83dd 100644 --- a/crates/omnigraph-server/Cargo.toml +++ b/crates/omnigraph-server/Cargo.toml @@ -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 } diff --git a/crates/omnigraph-server/src/api.rs b/crates/omnigraph-server/src/api.rs index b9e8bea..ff5d453 100644 --- a/crates/omnigraph-server/src/api.rs +++ b/crates/omnigraph-server/src/api.rs @@ -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, } -#[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, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct BranchCreateRequest { pub from: Option, 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, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct BranchListOutput { pub branches: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct BranchDeleteOutput { pub uri: String, pub name: String, pub actor_id: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct BranchMergeRequest { pub source: String, pub target: Option, } -#[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, } -#[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 for MergeConflictKindOutput { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct MergeConflictOutput { pub table_key: String, pub row_id: Option, @@ -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, pub snapshot: Option, } -#[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, } -#[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, pub actor_id: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CommitOutput { pub graph_commit_id: String, pub manifest_branch: Option, @@ -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, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ReadRequest { pub query_source: String, pub query_name: Option, @@ -238,7 +256,7 @@ pub struct ReadRequest { pub snapshot: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ChangeRequest { pub query_source: String, pub query_name: Option, @@ -246,30 +264,32 @@ pub struct ChangeRequest { pub branch: Option, } -#[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)] pub steps: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct IngestRequest { pub branch: Option, pub from: Option, + #[schema(value_type = Option)] pub mode: Option, pub data: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ExportRequest { pub branch: Option, #[serde(default)] @@ -278,17 +298,17 @@ pub struct ExportRequest { pub table_keys: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, IntoParams)] pub struct SnapshotQuery { pub branch: Option, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, IntoParams)] pub struct CommitListQuery { pub branch: Option, } -#[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, } -#[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")] diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index 0ca4d40..585e448 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -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 { Json(HealthOutput { status: "ok".to_string(), @@ -423,6 +476,10 @@ async fn server_health() -> Json { }) } +async fn server_openapi() -> Json { + Json(ApiDoc::openapi()) +} + async fn require_bearer_auth( State(state): State, 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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, @@ -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, actor: Option>, diff --git a/crates/omnigraph-server/tests/openapi.rs b/crates/omnigraph-server/tests/openapi.rs new file mode 100644 index 0000000..51d4280 --- /dev/null +++ b/crates/omnigraph-server/tests/openapi.rs @@ -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) -> (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"); +}