mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-09 01:35:18 +02:00
Merge pull request #1 from ModernRelay/claude/review-rust-api-JYSCx
Add OpenAPI documentation endpoint and schema
This commit is contained in:
commit
e5528047a9
6 changed files with 1342 additions and 31 deletions
25
Cargo.lock
generated
25
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -47,6 +47,51 @@ 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) {
|
||||
openapi
|
||||
.components
|
||||
.get_or_insert_with(Default::default)
|
||||
.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 +431,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 +461,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 +477,37 @@ async fn server_health() -> Json<HealthOutput> {
|
|||
})
|
||||
}
|
||||
|
||||
async fn server_openapi(State(state): State<AppState>) -> Json<utoipa::openapi::OpenApi> {
|
||||
let mut doc = ApiDoc::openapi();
|
||||
if !state.requires_bearer_auth() {
|
||||
strip_security(&mut doc);
|
||||
}
|
||||
Json(doc)
|
||||
}
|
||||
|
||||
fn strip_security(doc: &mut utoipa::openapi::OpenApi) {
|
||||
if let Some(components) = doc.components.as_mut() {
|
||||
components.security_schemes.clear();
|
||||
}
|
||||
for path_item in doc.paths.paths.values_mut() {
|
||||
for op in [
|
||||
path_item.get.as_mut(),
|
||||
path_item.post.as_mut(),
|
||||
path_item.put.as_mut(),
|
||||
path_item.delete.as_mut(),
|
||||
path_item.options.as_mut(),
|
||||
path_item.head.as_mut(),
|
||||
path_item.patch.as_mut(),
|
||||
path_item.trace.as_mut(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
op.security = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_bearer_auth(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
|
|
@ -486,6 +571,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 +611,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 +680,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 +738,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 +796,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 +834,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 +903,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 +939,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 +986,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 +1030,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 +1075,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 +1112,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 +1154,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 +1203,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 +1251,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 +1292,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>>,
|
||||
|
|
|
|||
964
crates/omnigraph-server/tests/openapi.rs
Normal file
964
crates/omnigraph-server/tests/openapi.rs
Normal file
|
|
@ -0,0 +1,964 @@
|
|||
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 app_for_loaded_repo_with_auth(token: &str) -> (tempfile::TempDir, Router) {
|
||||
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(token.to_string()),
|
||||
);
|
||||
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());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Open-mode vs auth-mode: served spec reflects runtime config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_mode_spec_has_no_security_schemes() {
|
||||
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 schemes = &json["components"]["securitySchemes"];
|
||||
assert!(
|
||||
schemes.is_null() || schemes.as_object().is_some_and(|m| m.is_empty()),
|
||||
"open-mode spec should have no security schemes"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_mode_spec_has_no_operation_security() {
|
||||
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 paths = json["paths"].as_object().unwrap();
|
||||
for (path, methods) in paths {
|
||||
for (method, operation) in methods.as_object().unwrap() {
|
||||
let security = &operation["security"];
|
||||
assert!(
|
||||
security.is_null(),
|
||||
"open-mode: {method} {path} should have no security requirement"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_mode_spec_includes_bearer_token_security_scheme() {
|
||||
let (_temp, app) = app_for_loaded_repo_with_auth("secret").await;
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/openapi.json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let (_, json) = json_response(&app, request).await;
|
||||
let scheme = &json["components"]["securitySchemes"]["bearer_token"];
|
||||
assert_eq!(scheme["type"].as_str().unwrap(), "http");
|
||||
assert_eq!(scheme["scheme"].as_str().unwrap(), "bearer");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_mode_spec_has_security_on_protected_operations() {
|
||||
let (_temp, app) = app_for_loaded_repo_with_auth("secret").await;
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/openapi.json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let (_, json) = json_response(&app, request).await;
|
||||
let protected_paths = [
|
||||
("/read", "post"),
|
||||
("/change", "post"),
|
||||
("/snapshot", "get"),
|
||||
("/branches", "get"),
|
||||
("/runs", "get"),
|
||||
("/commits", "get"),
|
||||
];
|
||||
for (path, method) in protected_paths {
|
||||
let security = &json["paths"][path][method]["security"];
|
||||
let arr = security
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("auth-mode: {method} {path} missing security"));
|
||||
let has_bearer = arr
|
||||
.iter()
|
||||
.any(|s| s.as_object().unwrap().contains_key("bearer_token"));
|
||||
assert!(
|
||||
has_bearer,
|
||||
"auth-mode: {method} {path} should require bearer_token"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_mode_spec_matches_static_generation() {
|
||||
let (_temp, app) = app_for_loaded_repo_with_auth("secret").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,
|
||||
"auth-mode served spec must match static generation"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_mode_healthz_still_has_no_security() {
|
||||
let (_temp, app) = app_for_loaded_repo_with_auth("secret").await;
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/openapi.json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let (_, json) = json_response(&app, request).await;
|
||||
let healthz = &json["paths"]["/healthz"]["get"];
|
||||
assert!(
|
||||
healthz.get("security").is_none() || healthz["security"].is_null(),
|
||||
"auth-mode: /healthz should still have no security"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue