diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 7951f37..c29cac6 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -18,7 +18,7 @@ use omnigraph_server::api::{ BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput, BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput, CommitOutput, ErrorOutput, ExportRequest, IngestOutput, IngestRequest, ReadOutput, ReadRequest, - RunListOutput, RunOutput, SchemaApplyOutput, SchemaApplyRequest, SnapshotOutput, + RunListOutput, RunOutput, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, SnapshotTableOutput, commit_output, ingest_output, read_output, run_output, schema_apply_output, snapshot_payload, }; @@ -303,6 +303,18 @@ enum SchemaCommand { #[arg(long)] json: bool, }, + /// Show the current accepted schema source + #[command(alias = "get")] + Show { + /// Repo URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, + }, } #[derive(Debug, Subcommand)] @@ -2003,6 +2015,37 @@ async fn main() -> Result<()> { print_schema_apply_human(&output); } } + SchemaCommand::Show { + uri, + target, + config, + json, + } => { + let config = load_cli_config(config.as_ref())?; + let bearer_token = + resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; + let uri = resolve_uri(&config, uri, target.as_deref())?; + let output = if is_remote_uri(&uri) { + remote_json::( + &http_client, + Method::GET, + remote_url(&uri, "/schema"), + None, + bearer_token.as_deref(), + ) + .await? + } else { + let db = Omnigraph::open(&uri).await?; + SchemaOutput { + schema_source: db.schema_source().to_string(), + } + }; + if json { + print_json(&output)?; + } else { + println!("{}", output.schema_source); + } + } }, Command::Query { command } => match command { QueryCommand::Lint { diff --git a/crates/omnigraph-server/src/api.rs b/crates/omnigraph-server/src/api.rs index ff5d453..61770df 100644 --- a/crates/omnigraph-server/src/api.rs +++ b/crates/omnigraph-server/src/api.rs @@ -280,6 +280,11 @@ pub struct SchemaApplyOutput { pub steps: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SchemaOutput { + pub schema_source: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct IngestRequest { pub branch: Option, diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index e8d0e7d..52d2718 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -14,7 +14,7 @@ use api::{ BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput, CommitListQuery, ErrorCode, ErrorOutput, ExportRequest, HealthOutput, IngestOutput, IngestRequest, ReadOutput, ReadRequest, RunListOutput, SchemaApplyOutput, SchemaApplyRequest, - SnapshotQuery, ingest_output, schema_apply_output, snapshot_payload, + SchemaOutput, SnapshotQuery, ingest_output, schema_apply_output, snapshot_payload, }; use axum::body::{Body, Bytes}; use axum::extract::DefaultBodyLimit; @@ -63,6 +63,7 @@ use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme}; server_export, server_change, server_schema_apply, + server_schema_get, server_ingest, server_branch_list, server_branch_create, @@ -407,6 +408,7 @@ pub fn build_app(state: AppState) -> Router { .route("/export", post(server_export)) .route("/read", post(server_read)) .route("/change", post(server_change)) + .route("/schema", get(server_schema_get)) .route("/schema/apply", post(server_schema_apply)) .route( "/ingest", @@ -796,6 +798,41 @@ async fn server_change( })) } +#[utoipa::path( + get, + path = "/schema", + tag = "schema", + responses( + (status = 200, description = "Current schema source", body = SchemaOutput), + (status = 401, description = "Unauthorized", body = ErrorOutput), + (status = 403, description = "Forbidden", body = ErrorOutput), + ), + security(("bearer_token" = [])), +)] +async fn server_schema_get( + State(state): State, + actor: Option>, +) -> std::result::Result, ApiError> { + authorize_request( + &state, + actor.as_ref().map(|Extension(actor)| actor), + PolicyRequest { + actor_id: actor + .as_ref() + .map(|Extension(actor)| actor.as_str().to_string()) + .unwrap_or_default(), + action: PolicyAction::Read, + branch: None, + target_branch: None, + }, + )?; + let schema_source = { + let db = Arc::clone(&state.db).read_owned().await; + db.schema_source().to_string() + }; + Ok(Json(SchemaOutput { schema_source })) +} + #[utoipa::path( post, path = "/schema/apply", diff --git a/crates/omnigraph-server/tests/openapi.rs b/crates/omnigraph-server/tests/openapi.rs index f47ccdf..d8e0cc7 100644 --- a/crates/omnigraph-server/tests/openapi.rs +++ b/crates/omnigraph-server/tests/openapi.rs @@ -161,6 +161,7 @@ const EXPECTED_PATHS: &[&str] = &[ "/read", "/export", "/change", + "/schema", "/schema/apply", "/ingest", "/branches", diff --git a/crates/omnigraph-server/tests/server.rs b/crates/omnigraph-server/tests/server.rs index feebdc6..7a00c51 100644 --- a/crates/omnigraph-server/tests/server.rs +++ b/crates/omnigraph-server/tests/server.rs @@ -10,7 +10,7 @@ use omnigraph::db::{Omnigraph, ReadTarget}; use omnigraph::loader::{LoadMode, load_jsonl}; use omnigraph_server::api::{ BranchCreateRequest, BranchMergeRequest, ChangeRequest, ErrorOutput, ExportRequest, - IngestRequest, ReadRequest, SchemaApplyRequest, + IngestRequest, ReadRequest, SchemaApplyRequest, SchemaOutput, }; use omnigraph_server::{AppState, build_app}; use serde_json::{Value, json}; @@ -1042,6 +1042,93 @@ async fn snapshot_route_returns_manifest_dataset_version() { assert!(snapshot_body["tables"].is_array()); } +#[tokio::test(flavor = "multi_thread")] +async fn schema_route_returns_current_source() { + let (_temp, app) = app_for_loaded_repo().await; + let (status, body) = json_response( + &app, + Request::builder() + .uri("/schema") + .method(Method::GET) + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!(status, StatusCode::OK); + let output: SchemaOutput = serde_json::from_value(body).unwrap(); + assert!(output.schema_source.contains("node Person")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn schema_route_requires_bearer_token_when_auth_configured() { + let (_temp, app) = app_for_loaded_repo_with_auth("demo-token").await; + + let (missing_status, missing_body) = json_response( + &app, + Request::builder() + .uri("/schema") + .method(Method::GET) + .body(Body::empty()) + .unwrap(), + ) + .await; + let missing_error: ErrorOutput = serde_json::from_value(missing_body).unwrap(); + assert_eq!(missing_status, StatusCode::UNAUTHORIZED); + assert_eq!( + missing_error.code, + Some(omnigraph_server::api::ErrorCode::Unauthorized) + ); + + let (ok_status, ok_body) = json_response( + &app, + Request::builder() + .uri("/schema") + .method(Method::GET) + .header("authorization", "Bearer demo-token") + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(ok_status, StatusCode::OK); + let output: SchemaOutput = serde_json::from_value(ok_body).unwrap(); + assert!(!output.schema_source.is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn schema_route_denied_when_actor_lacks_read_permission() { + let temp = init_loaded_repo().await; + let repo = repo_path(temp.path()); + let policy_path = temp.path().join("policy.yaml"); + // Policy grants branch_create only — no read action for act-bruno. + fs::write(&policy_path, INGEST_CREATE_ONLY_POLICY_YAML).unwrap(); + let state = AppState::open_with_bearer_tokens_and_policy( + repo.to_string_lossy().to_string(), + vec![("act-bruno".to_string(), "team-token".to_string())], + Some(&policy_path), + ) + .await + .unwrap(); + let app = build_app(state); + + let (status, body) = json_response( + &app, + Request::builder() + .uri("/schema") + .method(Method::GET) + .header("authorization", "Bearer team-token") + .body(Body::empty()) + .unwrap(), + ) + .await; + let error: ErrorOutput = serde_json::from_value(body).unwrap(); + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!( + error.code, + Some(omnigraph_server::api::ErrorCode::Forbidden) + ); +} + #[tokio::test(flavor = "multi_thread")] async fn policy_blocks_change_on_protected_main_but_allows_unprotected_branch() { let temp = init_loaded_repo().await;