Skip to main content

cognee_http_server/routers/
visualize.rs

1//! `/api/v1/visualize` — knowledge-graph HTML visualization router.
2//!
3//! - `GET /` renders a single-dataset visualization (HTML body).
4//! - `POST /multi` aggregates multiple `(user_id, dataset_id)` pairs into one
5//!   visualization. Superuser-only.
6//!
7//! Both endpoints emit `text/html` on success and JSON on error. Permission
8//! denied / dataset-not-found / internal errors all collapse into a single
9//! 409 envelope per Python parity — see
10//! `docs/http-server/routers/visualize.md` §2.
11//!
12//! **Per-router parity quirk** — Python's broad `except Exception` swallows
13//! 403/404/500 into 409. Do NOT "fix" it; cross-SDK parity tests assert this
14//! behavior.
15
16use axum::{
17    Router,
18    extract::{Query, State},
19    http::StatusCode,
20    response::Html,
21    routing::{get, post},
22};
23
24use cognee_database::IngestDb;
25
26use crate::auth::AuthenticatedUser;
27use crate::dto::visualize::{UserDatasetPairDTO, VisualizeQueryDTO};
28use crate::error::ApiError;
29use crate::middleware::validation::Json as ValidatedJson;
30use crate::state::AppState;
31
32/// Build the `/api/v1/visualize` sub-router.
33pub fn router() -> Router<AppState> {
34    Router::new()
35        .route("/", get(get_visualize))
36        .route("/multi", post(post_visualize_multi))
37}
38
39// ─── GET /api/v1/visualize ────────────────────────────────────────────────────
40
41/// `GET /api/v1/visualize?dataset_id=<uuid>` — render a single-dataset HTML.
42///
43/// Permission denied, dataset not found, graph DB read errors, render
44/// failures — all collapse into a 409 with the `{error}` envelope. Python
45/// parity quirk; see module docs.
46#[utoipa::path(
47    get,
48    path = "/api/v1/visualize",
49    tag = "visualize",
50    params(("dataset_id" = uuid::Uuid, Query, description = "Target dataset")),
51    responses(
52        (status = 200, description = "HTML visualization", content_type = "text/html"),
53        (status = 401, description = "unauthorized"),
54        (status = 409, description = "catch-all"),
55        (status = 422, description = "missing or malformed dataset_id"),
56    )
57)]
58#[tracing::instrument(name = "cognee.api.visualize", skip(state), fields(cognee.dataset.id = %query.dataset_id))]
59pub async fn get_visualize(
60    user: AuthenticatedUser,
61    State(state): State<AppState>,
62    Query(query): Query<VisualizeQueryDTO>,
63) -> Result<Html<String>, ApiError> {
64    crate::telemetry::emit(
65        "Visualize API Endpoint Invoked",
66        user.id,
67        serde_json::json!({
68            "endpoint": "GET /v1/visualize",
69            "dataset_id": query.dataset_id.to_string(),
70        }),
71    );
72
73    let components = state.components().ok_or_else(|| {
74        ApiError::VisualizeError(StatusCode::CONFLICT, "components not wired".into())
75    })?;
76
77    // Resolve and authorize. Permission denied collapses into 409 — see module
78    // docs. Do NOT return 403 here.
79    let db = components.database.clone();
80    let dataset = IngestDb::get_dataset(db.as_ref(), query.dataset_id)
81        .await
82        .map_err(|err| ApiError::VisualizeError(StatusCode::CONFLICT, err.to_string()))?
83        .ok_or_else(|| {
84            ApiError::VisualizeError(
85                StatusCode::CONFLICT,
86                format!("dataset {} not found", query.dataset_id),
87            )
88        })?;
89    let allowed = if let Some(ref acl) = components.acl_db {
90        acl.has_permission(user.id, dataset.id, "read")
91            .await
92            .map_err(|err| ApiError::VisualizeError(StatusCode::CONFLICT, err.to_string()))?
93    } else {
94        // No ACL backend wired (pure-OSS); allow.
95        true
96    };
97    if !allowed {
98        return Err(ApiError::VisualizeError(
99            StatusCode::CONFLICT,
100            "permission denied".to_string(),
101        ));
102    }
103
104    let Some(graph_db) = components.graph_db.clone() else {
105        return Err(ApiError::VisualizeError(
106            StatusCode::CONFLICT,
107            "graph database is not wired".to_string(),
108        ));
109    };
110
111    let html = cognee_visualization::render(graph_db.as_ref())
112        .await
113        .map_err(|err| ApiError::VisualizeError(StatusCode::CONFLICT, err.to_string()))?;
114    Ok(Html(html))
115}
116
117// ─── POST /api/v1/visualize/multi ─────────────────────────────────────────────
118
119/// `POST /api/v1/visualize/multi` — render a combined multi-user visualization.
120///
121/// Superuser-only. The 403 envelope is emitted by the `SuperuserOnly`
122/// extractor and uses `{error}`, NOT `{detail}`.
123#[utoipa::path(
124    post,
125    path = "/api/v1/visualize/multi",
126    tag = "visualize",
127    request_body = Vec<UserDatasetPairDTO>,
128    responses(
129        (status = 200, description = "HTML visualization", content_type = "text/html"),
130        (status = 401, description = "unauthorized"),
131        (status = 403, description = "superuser required"),
132        (status = 409, description = "catch-all"),
133    )
134)]
135#[tracing::instrument(name = "cognee.api.visualize.multi", skip(state, pairs))]
136pub async fn post_visualize_multi(
137    user: AuthenticatedUser,
138    State(state): State<AppState>,
139    ValidatedJson(pairs): ValidatedJson<Vec<UserDatasetPairDTO>>,
140) -> Result<Html<String>, ApiError> {
141    if !user.is_superuser {
142        // Python parity: superuser gate is a 403 with the
143        // `VisualizeError` envelope, not the canonical 403 detail body.
144        return Err(ApiError::VisualizeError(
145            StatusCode::FORBIDDEN,
146            "Superuser privileges required for multi-user visualization".to_string(),
147        ));
148    }
149    crate::telemetry::emit(
150        "Visualize Multi API Endpoint Invoked",
151        user.id,
152        serde_json::json!({
153            "endpoint": "POST /v1/visualize/multi",
154            "pair_count": pairs.len(),
155        }),
156    );
157
158    let components = state.components().ok_or_else(|| {
159        ApiError::VisualizeError(StatusCode::CONFLICT, "components not wired".into())
160    })?;
161
162    // Per Python parity, permission is resolved against the *target* user, not
163    // the caller — so the superuser does not implicitly elevate access.
164    let db = components.database.clone();
165    let mut user_pairs: Vec<(String, std::sync::Arc<dyn cognee_graph::GraphDBTrait>)> = Vec::new();
166    for pair in &pairs {
167        let dataset = IngestDb::get_dataset(db.as_ref(), pair.dataset_id)
168            .await
169            .map_err(|err| ApiError::VisualizeError(StatusCode::CONFLICT, err.to_string()))?
170            .ok_or_else(|| {
171                ApiError::VisualizeError(
172                    StatusCode::CONFLICT,
173                    format!("dataset {} not found", pair.dataset_id),
174                )
175            })?;
176        // OSS does not bundle an ACL backend — when no `acl_db` is wired
177        // (the pure-OSS case), allow the read. Closed embedders install
178        // a real `AclDb` impl via `ComponentHandles::acl_db`.
179        let allowed = if let Some(ref acl) = components.acl_db {
180            acl.has_permission(pair.user_id, dataset.id, "read")
181                .await
182                .map_err(|err| ApiError::VisualizeError(StatusCode::CONFLICT, err.to_string()))?
183        } else {
184            true
185        };
186        if !allowed {
187            return Err(ApiError::VisualizeError(
188                StatusCode::CONFLICT,
189                "permission denied".to_string(),
190            ));
191        }
192        let Some(graph_db) = components.graph_db.clone() else {
193            return Err(ApiError::VisualizeError(
194                StatusCode::CONFLICT,
195                "graph database is not wired".to_string(),
196            ));
197        };
198
199        // The closed-side `users` table moved out of OSS, so
200        // OSS falls back to the user id as the palette key. Closed
201        // embedders that want the email-keyed palette wrap this router
202        // and substitute their own email lookup.
203        let user_label = pair.user_id.to_string();
204
205        user_pairs.push((user_label, graph_db));
206    }
207
208    let html = cognee_visualization::render_multi_user(&user_pairs)
209        .await
210        .map_err(|err| ApiError::VisualizeError(StatusCode::CONFLICT, err.to_string()))?;
211    Ok(Html(html))
212}