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

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>>,