use axum::{
Json, Router,
body::Bytes,
extract::{DefaultBodyLimit, Path, Query, State},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, NoContent, Response},
routing::{delete, get, post, put},
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use crate::ast::{Document, NodeId, Tag, Title};
use crate::embedding::EmbeddingClient;
use crate::org_meta;
use crate::parser;
use crate::storage;
pub struct AppState {
pub conn: Mutex<rusqlite::Connection>,
pub embedding_client: Option<Arc<dyn EmbeddingClient>>,
pub embedding_model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeView {
pub id: NodeId,
pub title: Title,
pub tags: Vec<Tag>,
pub document: Document,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "updatedAt")]
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeSummary {
pub id: NodeId,
pub title: Title,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateNodeRequest {
pub id: Option<String>,
pub document: Document,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateNodeRequest {
pub document: Document,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NeighborEdge {
pub target: String,
#[serde(rename = "linkType")]
pub link_type: storage::LinkType,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NeighborhoodResponse {
pub outgoing: Vec<NeighborEdge>,
pub incoming: Vec<NeighborEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RelinkResponse {
pub nodes: u64,
pub links: u64,
}
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct ListNodesQuery {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, Deserialize, Default)]
pub struct CreateNodeQuery {
pub id: Option<String>,
}
pub const REQUEST_BODY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
#[must_use]
pub fn build_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/nodes/{id}", get(get_node_handler))
.route("/nodes/{id}", put(put_node_handler))
.route("/nodes/{id}", delete(delete_node_handler))
.route("/nodes/{id}/neighbors", get(neighbors_handler))
.route("/nodes", get(list_nodes_handler))
.route("/nodes", post(post_node_handler))
.route("/search", get(search_handler))
.route("/tags/{tag}", get(tags_handler))
.route("/recent", get(recent_handler))
.route("/admin/relink", post(relink_handler))
.layer(DefaultBodyLimit::max(REQUEST_BODY_LIMIT_BYTES))
.with_state(state)
}
async fn get_node_handler(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
let nf = storage::get_node_full(&conn, &id)
.map_err(|_| AppError::Internal)?
.ok_or(AppError::NotFound)?;
Ok(render_node_view(&headers, to_node_view(&id, &nf)))
}
async fn put_node_handler(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, AppError> {
let req = parse_update_body(&headers, &body, &id)?;
let embedding = compute_doc_embedding(&state, &req.document, &id).await;
let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
let ok = storage::update_node_with(
&conn,
&id,
&req.document,
embedding,
state.embedding_model.as_deref(),
)
.map_err(|_| AppError::Internal)?;
if !ok {
return Err(AppError::NotFound);
}
let nf = storage::get_node_full(&conn, &id)
.map_err(|_| AppError::Internal)?
.ok_or(AppError::Internal)?;
Ok(render_node_view(&headers, to_node_view(&id, &nf)))
}
async fn delete_node_handler(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<NoContent, AppError> {
let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
let ok = storage::delete_node(&conn, &id).map_err(|_| AppError::Internal)?;
if ok {
Ok(NoContent)
} else {
Err(AppError::NotFound)
}
}
async fn post_node_handler(
State(state): State<Arc<AppState>>,
Query(query): Query<CreateNodeQuery>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, AppError> {
let req = parse_create_body(&headers, &body)?;
let nid = {
let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
match query.id.or_else(|| req.id.clone()) {
Some(supplied) => {
if storage::get_node(&conn, &supplied)
.map_err(|_| AppError::Internal)?
.is_some()
{
return Err(AppError::Conflict);
}
supplied
}
None => uuid::Uuid::new_v4().to_string(),
}
};
let embedding = compute_doc_embedding(&state, &req.document, &nid).await;
let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
storage::insert_node_with(
&conn,
&nid,
&req.document,
embedding,
state.embedding_model.as_deref(),
)
.map_err(|_| AppError::Internal)?;
let nf = storage::get_node_full(&conn, &nid)
.map_err(|_| AppError::Internal)?
.ok_or(AppError::Internal)?;
Ok(render_created_node_view(&headers, to_node_view(&nid, &nf)))
}
async fn neighbors_handler(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<NeighborhoodResponse>, AppError> {
let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
let n = storage::get_neighborhood(&conn, &id).map_err(|_| AppError::Internal)?;
let outgoing = n
.outgoing
.into_iter()
.map(|(t, lt)| NeighborEdge {
target: t.0,
link_type: lt,
})
.collect();
let incoming = n
.incoming
.into_iter()
.map(|(t, lt)| NeighborEdge {
target: t.0,
link_type: lt,
})
.collect();
Ok(Json(NeighborhoodResponse { outgoing, incoming }))
}
async fn list_nodes_handler(
State(state): State<Arc<AppState>>,
Query(query): Query<ListNodesQuery>,
) -> Result<Json<Vec<NodeSummary>>, AppError> {
let limit = query.limit.unwrap_or(100).clamp(1, 1000);
let offset = query.offset.unwrap_or(0).max(0);
let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
let rows = storage::list_all_nodes(&conn, limit as usize, offset as usize)
.map_err(|_| AppError::Internal)?;
Ok(Json(
rows.into_iter()
.map(|(id, title)| NodeSummary { id, title })
.collect(),
))
}
async fn search_handler(
State(state): State<Arc<AppState>>,
Query(query): Query<SearchQuery>,
) -> Result<Json<Vec<NodeSummary>>, AppError> {
let q = query.q.unwrap_or_default();
if q.trim().is_empty() {
return Ok(Json(vec![]));
}
let query_embedding: Option<(Vec<f32>, &str)> = match (
state.embedding_client.as_ref(),
state.embedding_model.as_deref(),
) {
(Some(client), Some(model)) => match client.embed(&q).await {
Ok(v) => Some((v, model)),
Err(err) => {
tracing::error!(error = %err, "kb /search: embedding query failed");
None
}
},
_ => None,
};
let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
let ids: Vec<String> = storage::search_hybrid(&conn, &q, query_embedding)
.map_err(|_| AppError::Internal)?
.into_iter()
.map(|n| n.0)
.collect();
let titles = storage::fetch_titles(&conn, &ids).map_err(|_| AppError::Internal)?;
Ok(Json(
titles
.into_iter()
.map(|(id, title)| NodeSummary {
id: NodeId(id),
title: Title(title),
})
.collect(),
))
}
async fn tags_handler(
State(state): State<Arc<AppState>>,
Path(tag): Path<String>,
) -> Result<Json<Vec<NodeSummary>>, AppError> {
let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
let rows = storage::list_by_tag(&conn, &tag).map_err(|_| AppError::Internal)?;
let ids: Vec<String> = rows.iter().map(|r| r.id.0.clone()).collect();
let titles = storage::fetch_titles(&conn, &ids).map_err(|_| AppError::Internal)?;
Ok(Json(
titles
.into_iter()
.map(|(id, title)| NodeSummary {
id: NodeId(id),
title: Title(title),
})
.collect(),
))
}
async fn recent_handler(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<NodeSummary>>, AppError> {
let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
let rows = storage::list_recent(&conn, 50).map_err(|_| AppError::Internal)?;
let ids: Vec<String> = rows.iter().map(|r| r.id.0.clone()).collect();
let titles = storage::fetch_titles(&conn, &ids).map_err(|_| AppError::Internal)?;
Ok(Json(
titles
.into_iter()
.map(|(id, title)| NodeSummary {
id: NodeId(id),
title: Title(title),
})
.collect(),
))
}
async fn relink_handler(
State(state): State<Arc<AppState>>,
) -> Result<Json<RelinkResponse>, AppError> {
let conn = state.conn.lock().map_err(|_| AppError::Internal)?;
let (nodes, links) = storage::relink_all(&conn).map_err(|_| AppError::Internal)?;
Ok(Json(RelinkResponse {
nodes: nodes as u64,
links: links as u64,
}))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WireFormat {
Json,
Org,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RequestFormat {
Json,
Org,
Markdown,
}
const ORG_CONTENT_TYPE: &str = "text/org";
fn negotiate_response(headers: &HeaderMap) -> WireFormat {
let Some(accept) = headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()) else {
return WireFormat::Json;
};
for entry in accept.split(',') {
let mime = entry.split(';').next().unwrap_or("").trim();
match mime {
"application/json" | "*/*" | "application/*" => return WireFormat::Json,
"text/org" | "text/plain" => return WireFormat::Org,
_ => {}
}
}
WireFormat::Json
}
fn request_format(headers: &HeaderMap) -> RequestFormat {
let Some(ct) = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
else {
return RequestFormat::Json;
};
let mime = ct.split(';').next().unwrap_or("").trim();
match mime {
"text/org" | "text/plain" => RequestFormat::Org,
"text/markdown" => RequestFormat::Markdown,
_ => RequestFormat::Json,
}
}
fn parse_markdown_body(body: &[u8]) -> Result<Document, AppError> {
let text = std::str::from_utf8(body)
.map_err(|e| AppError::BadRequest(format!("markdown body is not valid UTF-8: {e}")))?;
crate::markdown::markdown_to_document(text).map_err(|e| match e {
crate::markdown::MarkdownError::PandocNotFound => AppError::Internal,
other => AppError::BadRequest(format!("markdown conversion failure: {other}")),
})
}
fn parse_create_body(headers: &HeaderMap, body: &[u8]) -> Result<CreateNodeRequest, AppError> {
match request_format(headers) {
RequestFormat::Json => serde_json::from_slice::<CreateNodeRequest>(body)
.map_err(|e| AppError::BadRequest(format!("invalid JSON body: {e}"))),
RequestFormat::Org => {
let mut document = parse_org_body(body)?;
let _ = org_meta::hydrate(&mut document);
Ok(CreateNodeRequest { id: None, document })
}
RequestFormat::Markdown => {
let document = parse_markdown_body(body)?;
Ok(CreateNodeRequest { id: None, document })
}
}
}
fn parse_update_body(
headers: &HeaderMap,
body: &[u8],
node_id: &str,
) -> Result<UpdateNodeRequest, AppError> {
match request_format(headers) {
RequestFormat::Json => serde_json::from_slice::<UpdateNodeRequest>(body)
.map_err(|e| AppError::BadRequest(format!("invalid JSON body: {e}"))),
RequestFormat::Org => {
let mut document = parse_org_body(body)?;
if let Some(drawer_id) = org_meta::hydrate(&mut document) {
if drawer_id != node_id {
return Err(AppError::BadRequest(format!(
"document :ID: {drawer_id} does not match target node id {node_id}"
)));
}
}
Ok(UpdateNodeRequest { document })
}
RequestFormat::Markdown => {
let document = parse_markdown_body(body)?;
Ok(UpdateNodeRequest { document })
}
}
}
fn parse_org_body(body: &[u8]) -> Result<Document, AppError> {
let text = std::str::from_utf8(body)
.map_err(|e| AppError::BadRequest(format!("org body is not valid UTF-8: {e}")))?;
parser::parse_document(text)
.map_err(|e| AppError::BadRequest(format!("org parse failure: {e}")))
}
fn render_node_view(headers: &HeaderMap, view: NodeView) -> Response {
match negotiate_response(headers) {
WireFormat::Json => Json(view).into_response(),
WireFormat::Org => render_org(&view),
}
}
fn render_created_node_view(headers: &HeaderMap, view: NodeView) -> Response {
match negotiate_response(headers) {
WireFormat::Json => (StatusCode::CREATED, Json(view)).into_response(),
WireFormat::Org => {
let body = org_meta::render_with_metadata(
view.id.as_str(),
&view.created_at,
&view.updated_at,
&view.document,
);
(
StatusCode::CREATED,
[(header::CONTENT_TYPE, ORG_CONTENT_TYPE)],
body,
)
.into_response()
}
}
}
fn render_org(view: &NodeView) -> Response {
let body = org_meta::render_with_metadata(
view.id.as_str(),
&view.created_at,
&view.updated_at,
&view.document,
);
([(header::CONTENT_TYPE, ORG_CONTENT_TYPE)], body).into_response()
}
async fn compute_doc_embedding(
state: &Arc<AppState>,
document: &Document,
node_id: &str,
) -> Option<Vec<f32>> {
let client = state.embedding_client.as_ref()?;
state.embedding_model.as_deref()?;
let title = storage::extract_title(document);
let body_text = storage::extract_body_text(document);
let payload = format!("{title}\n{body_text}");
match client.embed(&payload).await {
Ok(v) => Some(v),
Err(err) => {
tracing::error!(node_id, error = %err, "kb embed generation failed");
None
}
}
}
fn to_node_view(id: &str, nf: &storage::NodeFullData) -> NodeView {
NodeView {
id: NodeId(id.to_string()),
title: Title(nf.title.clone()),
tags: nf.tags.clone(),
document: nf.document.clone(),
created_at: nf.created_at.clone(),
updated_at: nf.updated_at.clone(),
}
}
enum AppError {
NotFound,
Conflict,
BadRequest(String),
Internal,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match self {
AppError::NotFound => (StatusCode::NOT_FOUND, "not found\n").into_response(),
AppError::Conflict => (StatusCode::CONFLICT, "id already exists\n").into_response(),
AppError::BadRequest(msg) => {
(StatusCode::BAD_REQUEST, format!("{msg}\n")).into_response()
}
AppError::Internal => {
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error\n").into_response()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::*;
use crate::generator;
use crate::storage;
use axum::body::Body;
use axum::http::Request;
use rusqlite::Connection;
use tower::ServiceExt;
fn setup_state() -> Arc<AppState> {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
storage::init_db(&conn).unwrap();
Arc::new(AppState {
conn: Mutex::new(conn),
embedding_client: None,
embedding_model: None,
})
}
fn sample_doc() -> Document {
Document {
blocks: vec![Block::Heading {
level: 1,
title: Title("Test".into()),
tags: vec![Tag("api".into())],
children: vec![Block::Paragraph {
inlines: vec![Inline::Plain("hello".into())],
}],
}],
}
}
fn link_doc(targets: &[&str]) -> Document {
let inlines: Vec<Inline> = targets
.iter()
.map(|t| Inline::Link {
target: format!("id:{t}"),
description: None,
})
.collect();
Document {
blocks: vec![Block::Paragraph { inlines }],
}
}
async fn send(
app: Router,
method: &str,
uri: &str,
content_type: Option<&str>,
accept: Option<&str>,
body: Vec<u8>,
) -> (StatusCode, HeaderMap, Vec<u8>) {
let mut req = Request::builder().method(method).uri(uri);
if let Some(ct) = content_type {
req = req.header(header::CONTENT_TYPE, ct);
}
if let Some(a) = accept {
req = req.header(header::ACCEPT, a);
}
let resp = app
.oneshot(req.body(Body::from(body)).unwrap())
.await
.unwrap();
let status = resp.status();
let headers = resp.headers().clone();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec();
(status, headers, bytes)
}
fn json_str(bytes: &[u8]) -> serde_json::Value {
serde_json::from_slice(bytes).expect("response was not valid JSON")
}
#[test]
fn node_view_roundtrip_json() {
let view = NodeView {
id: NodeId("abc".into()),
title: Title("Test".into()),
tags: vec![Tag("api".into())],
document: sample_doc(),
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
};
let json = serde_json::to_string(&view).unwrap();
let parsed: NodeView = serde_json::from_str(&json).unwrap();
assert_eq!(view, parsed);
}
#[test]
fn insert_and_get_node_full() {
let state = setup_state();
let conn = state.conn.lock().unwrap();
let nid = "test-1";
let doc = sample_doc();
storage::insert_node(&conn, nid, &doc).unwrap();
let nf = storage::get_node_full(&conn, nid).unwrap().unwrap();
assert_eq!(nf.title, "Test");
assert_eq!(nf.tags.len(), 1);
assert_eq!(nf.tags[0].0, "api");
assert_eq!(nf.document, doc);
}
#[test]
fn get_node_full_nonexistent() {
let state = setup_state();
let conn = state.conn.lock().unwrap();
let nf = storage::get_node_full(&conn, "no-such").unwrap();
assert!(nf.is_none());
}
#[test]
fn fetch_titles_works() {
let state = setup_state();
let conn = state.conn.lock().unwrap();
let doc = sample_doc();
storage::insert_node(&conn, "a", &doc).unwrap();
storage::insert_node(&conn, "b", &doc).unwrap();
let titles = storage::fetch_titles(&conn, &["a".into(), "b".into()]).unwrap();
assert_eq!(titles.len(), 2);
}
#[test]
fn search_fts_finds_results() {
let state = setup_state();
let conn = state.conn.lock().unwrap();
let doc = Document {
blocks: vec![Block::Heading {
level: 1,
title: Title("Rust Programming".into()),
tags: vec![],
children: vec![Block::Paragraph {
inlines: vec![Inline::Plain("systems programming language".into())],
}],
}],
};
storage::insert_node(&conn, "fts-1", &doc).unwrap();
let results = storage::search_fts(&conn, "programming").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0], "fts-1");
}
#[tokio::test]
async fn router_shape_listed_routes_respond() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "x", &sample_doc()).unwrap();
}
let app = build_router(state);
let expectations: &[(&str, &str, &str, &[u16])] = &[
("GET", "/nodes/x", "", &[200]),
("PUT", "/nodes/x", r#"{"document":{"blocks":[]}}"#, &[200]),
("DELETE", "/nodes/x", "", &[204]),
("GET", "/nodes/x/neighbors", "", &[200]),
("GET", "/nodes", "", &[200]),
(
"POST",
"/nodes",
r#"{"document":{"blocks":[]}}"#,
&[201, 409],
),
("GET", "/search?q=hi", "", &[200]),
("GET", "/tags/api", "", &[200]),
("GET", "/recent", "", &[200]),
("POST", "/admin/relink", "", &[200]),
];
for (method, uri, body, ok) in expectations {
let (status, _, _) = send(
app.clone(),
method,
uri,
if body.is_empty() {
None
} else {
Some("application/json")
},
Some("application/json"),
body.as_bytes().to_vec(),
)
.await;
assert!(
ok.contains(&status.as_u16()),
"{method} {uri} got {status}, expected one of {ok:?}"
);
}
}
#[tokio::test]
async fn router_shape_rejects_unknown_paths() {
let app = build_router(setup_state());
let (status, _, _) = send(app, "GET", "/no-such-route", None, None, vec![]).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn router_shape_rejects_wrong_method_on_admin_relink() {
let app = build_router(setup_state());
let (status, _, _) = send(app, "GET", "/admin/relink", None, None, vec![]).await;
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn router_shape_rejects_post_on_recent() {
let app = build_router(setup_state());
let (status, _, _) = send(app, "POST", "/recent", None, None, vec![]).await;
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn api_neighbors_empty_for_isolated_node() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "lonely", &sample_doc()).unwrap();
}
let app = build_router(state);
let (status, _, body) =
send(app, "GET", "/nodes/lonely/neighbors", None, None, vec![]).await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&body);
assert_eq!(v["outgoing"].as_array().unwrap().len(), 0);
assert_eq!(v["incoming"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn api_neighbors_returns_200_with_empty_for_unknown_id() {
let app = build_router(setup_state());
let (status, _, body) = send(app, "GET", "/nodes/nope/neighbors", None, None, vec![]).await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&body);
assert!(v["outgoing"].as_array().unwrap().is_empty());
assert!(v["incoming"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn api_neighbors_field_names_are_camelcase() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "a", &sample_doc()).unwrap();
storage::insert_node(&conn, "b", &sample_doc()).unwrap();
storage::insert_node(&conn, "c", &sample_doc()).unwrap();
storage::relink_one(&conn, "a", &link_doc(&["b"])).unwrap();
storage::relink_one(&conn, "c", &link_doc(&["a"])).unwrap();
}
let app = build_router(state);
let (status, _, body) = send(app, "GET", "/nodes/a/neighbors", None, None, vec![]).await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&body);
let outgoing = v["outgoing"].as_array().unwrap();
let incoming = v["incoming"].as_array().unwrap();
assert_eq!(outgoing.len(), 1);
assert_eq!(incoming.len(), 1);
assert_eq!(outgoing[0]["target"].as_str().unwrap(), "b");
assert_eq!(outgoing[0]["linkType"].as_str().unwrap(), "id");
assert_eq!(incoming[0]["target"].as_str().unwrap(), "c");
assert_eq!(incoming[0]["linkType"].as_str().unwrap(), "id");
assert!(outgoing[0].get("link_type").is_none());
}
#[tokio::test]
async fn api_list_all_orders_by_updated_at_desc() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "first", &sample_doc()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
storage::insert_node(&conn, "second", &sample_doc()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
storage::insert_node(&conn, "third", &sample_doc()).unwrap();
}
let app = build_router(state);
let (status, _, body) = send(app, "GET", "/nodes", None, None, vec![]).await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&body);
let arr = v.as_array().unwrap();
assert_eq!(arr.len(), 3);
assert_eq!(arr[0]["id"].as_str().unwrap(), "third");
assert_eq!(arr[1]["id"].as_str().unwrap(), "second");
assert_eq!(arr[2]["id"].as_str().unwrap(), "first");
}
#[tokio::test]
async fn api_list_all_paginates_with_limit_and_offset() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
for i in 0..5 {
storage::insert_node(&conn, &format!("n{i}"), &sample_doc()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
let app = build_router(state);
let (status, _, body) = send(
app.clone(),
"GET",
"/nodes?limit=2&offset=0",
None,
None,
vec![],
)
.await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&body);
assert_eq!(v.as_array().unwrap().len(), 2);
let (_, _, body2) = send(app, "GET", "/nodes?limit=2&offset=2", None, None, vec![]).await;
let v2 = json_str(&body2);
assert_eq!(v2.as_array().unwrap().len(), 2);
let p1: Vec<&str> = v
.as_array()
.unwrap()
.iter()
.map(|n| n["id"].as_str().unwrap())
.collect();
let p2: Vec<&str> = v2
.as_array()
.unwrap()
.iter()
.map(|n| n["id"].as_str().unwrap())
.collect();
for id in &p1 {
assert!(!p2.contains(id));
}
}
#[tokio::test]
async fn api_list_all_offset_past_end_returns_empty() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "only", &sample_doc()).unwrap();
}
let app = build_router(state);
let (status, _, body) = send(app, "GET", "/nodes?offset=999", None, None, vec![]).await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&body);
assert!(v.as_array().unwrap().is_empty());
}
#[tokio::test]
async fn api_list_all_default_limit_is_100() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
for i in 0..150 {
storage::insert_node(&conn, &format!("n{i:03}"), &sample_doc()).unwrap();
}
}
let app = build_router(state);
let (status, _, body) = send(app, "GET", "/nodes", None, None, vec![]).await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&body);
assert_eq!(v.as_array().unwrap().len(), 100);
}
#[tokio::test]
async fn api_relink_returns_node_and_link_counts() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "a", &link_doc(&["b"])).unwrap();
storage::insert_node(&conn, "b", &sample_doc()).unwrap();
}
let app = build_router(state);
let (status, _, body) = send(app, "POST", "/admin/relink", None, None, vec![]).await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&body);
assert_eq!(v["nodes"].as_u64().unwrap(), 2);
assert_eq!(v["links"].as_u64().unwrap(), 1);
assert!(v.get("nodes_processed").is_none());
assert!(v.get("links_written").is_none());
}
#[tokio::test]
async fn api_relink_works_on_empty_db() {
let app = build_router(setup_state());
let (status, _, body) = send(app, "POST", "/admin/relink", None, None, vec![]).await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&body);
assert_eq!(v["nodes"].as_u64().unwrap(), 0);
assert_eq!(v["links"].as_u64().unwrap(), 0);
}
#[tokio::test]
async fn api_create_id_query_uses_query_id_when_only_query_provided() {
let app = build_router(setup_state());
let body = serde_json::to_vec(&CreateNodeRequest {
id: None,
document: sample_doc(),
})
.unwrap();
let (status, _, resp) = send(
app,
"POST",
"/nodes?id=from-query",
Some("application/json"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::CREATED);
let v = json_str(&resp);
assert_eq!(v["id"].as_str().unwrap(), "from-query");
}
#[tokio::test]
async fn api_create_id_query_uses_body_id_when_no_query() {
let app = build_router(setup_state());
let body = serde_json::to_vec(&CreateNodeRequest {
id: Some("from-body".into()),
document: sample_doc(),
})
.unwrap();
let (status, _, resp) = send(
app,
"POST",
"/nodes",
Some("application/json"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::CREATED);
let v = json_str(&resp);
assert_eq!(v["id"].as_str().unwrap(), "from-body");
}
#[tokio::test]
async fn api_create_id_query_query_overrides_body() {
let app = build_router(setup_state());
let body = serde_json::to_vec(&CreateNodeRequest {
id: Some("from-body".into()),
document: sample_doc(),
})
.unwrap();
let (status, _, resp) = send(
app,
"POST",
"/nodes?id=from-query",
Some("application/json"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::CREATED);
let v = json_str(&resp);
assert_eq!(v["id"].as_str().unwrap(), "from-query");
}
#[tokio::test]
async fn api_create_id_query_mints_uuid_when_neither_supplied() {
let app = build_router(setup_state());
let body = serde_json::to_vec(&CreateNodeRequest {
id: None,
document: sample_doc(),
})
.unwrap();
let (status, _, resp) = send(
app,
"POST",
"/nodes",
Some("application/json"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::CREATED);
let v = json_str(&resp);
let id = v["id"].as_str().unwrap();
assert!(uuid::Uuid::parse_str(id).is_ok(), "expected UUID, got {id}");
}
#[tokio::test]
async fn api_create_id_query_conflict_via_query_returns_409() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "taken", &sample_doc()).unwrap();
}
let app = build_router(state);
let body = serde_json::to_vec(&CreateNodeRequest {
id: None,
document: sample_doc(),
})
.unwrap();
let (status, _, _) = send(
app,
"POST",
"/nodes?id=taken",
Some("application/json"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn api_create_id_query_conflict_via_body_returns_409() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "taken", &sample_doc()).unwrap();
}
let app = build_router(state);
let body = serde_json::to_vec(&CreateNodeRequest {
id: Some("taken".into()),
document: sample_doc(),
})
.unwrap();
let (status, _, _) = send(
app,
"POST",
"/nodes",
Some("application/json"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn api_orgtext_read_returns_org_for_text_org_accept() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "n", &sample_doc()).unwrap();
}
let app = build_router(state);
let (status, headers, body) =
send(app, "GET", "/nodes/n", None, Some("text/org"), vec![]).await;
assert_eq!(status, StatusCode::OK);
let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(ct.starts_with("text/org"), "content-type was {ct}");
let text = String::from_utf8(body).unwrap();
assert!(
text.starts_with(":PROPERTIES:\n:ID: n\n"),
"missing metadata drawer: {text}"
);
assert!(text.ends_with(&generator::generate(&sample_doc())));
assert!(!text.starts_with('{'), "expected org body, got JSON-ish");
}
#[tokio::test]
async fn api_orgtext_read_accepts_text_plain_as_synonym() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "n", &sample_doc()).unwrap();
}
let app = build_router(state);
let (status, headers, body) =
send(app, "GET", "/nodes/n", None, Some("text/plain"), vec![]).await;
assert_eq!(status, StatusCode::OK);
let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(ct.starts_with("text/org"), "content-type was {ct}");
let text = String::from_utf8(body).unwrap();
assert!(text.starts_with(":PROPERTIES:\n:ID: n\n"));
assert!(text.ends_with(&generator::generate(&sample_doc())));
}
#[tokio::test]
async fn api_orgtext_read_returns_json_when_accept_application_json() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "n", &sample_doc()).unwrap();
}
let app = build_router(state);
let (status, headers, body) = send(
app,
"GET",
"/nodes/n",
None,
Some("application/json"),
vec![],
)
.await;
assert_eq!(status, StatusCode::OK);
let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(ct.starts_with("application/json"), "content-type was {ct}");
let v = json_str(&body);
assert_eq!(v["id"].as_str().unwrap(), "n");
}
#[tokio::test]
async fn api_orgtext_read_defaults_to_json_when_accept_absent() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "n", &sample_doc()).unwrap();
}
let app = build_router(state);
let (status, headers, body) = send(app, "GET", "/nodes/n", None, None, vec![]).await;
assert_eq!(status, StatusCode::OK);
let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(ct.starts_with("application/json"));
let _ = json_str(&body);
}
#[tokio::test]
async fn api_orgtext_read_unknown_id_404_under_org_accept() {
let app = build_router(setup_state());
let (status, _, _) = send(app, "GET", "/nodes/nope", None, Some("text/org"), vec![]).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn api_orgtext_read_unknown_id_404_under_json_accept() {
let app = build_router(setup_state());
let (status, _, _) = send(
app,
"GET",
"/nodes/nope",
None,
Some("application/json"),
vec![],
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn api_orgtext_write_post_parses_org_body() {
let app = build_router(setup_state());
let body = b"* Hello\nworld\n".to_vec();
let (status, _, resp) = send(
app,
"POST",
"/nodes",
Some("text/org"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::CREATED);
let v = json_str(&resp);
assert_eq!(v["title"].as_str().unwrap(), "Hello");
let id = v["id"].as_str().unwrap();
assert!(uuid::Uuid::parse_str(id).is_ok());
}
fn pandoc_available() -> bool {
std::process::Command::new("pandoc")
.arg("--version")
.output()
.is_ok_and(|o| o.status.success())
}
#[tokio::test]
async fn api_markdown_write_post_converts_via_pandoc() {
if !pandoc_available() {
eprintln!("skipping: pandoc not on PATH");
return;
}
let app = build_router(setup_state());
let body = b"# Markdown Title\n\nsome ~~struck~~ body\n".to_vec();
let (status, _, resp) = send(
app,
"POST",
"/nodes",
Some("text/markdown"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::CREATED);
let v = json_str(&resp);
assert_eq!(v["title"].as_str().unwrap(), "Markdown Title");
}
#[tokio::test]
async fn api_markdown_write_put_converts_via_pandoc() {
if !pandoc_available() {
eprintln!("skipping: pandoc not on PATH");
return;
}
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "md-target", &sample_doc()).unwrap();
}
let app = build_router(state);
let (status, _, resp) = send(
app,
"PUT",
"/nodes/md-target",
Some("text/markdown"),
Some("application/json"),
b"# Replaced\n\nnew body\n".to_vec(),
)
.await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&resp);
assert_eq!(v["id"].as_str().unwrap(), "md-target");
assert_eq!(v["title"].as_str().unwrap(), "Replaced");
}
#[tokio::test]
async fn api_orgtext_write_post_accepts_text_plain_synonym() {
let app = build_router(setup_state());
let body = b"* Plain\nbody\n".to_vec();
let (status, _, resp) = send(
app,
"POST",
"/nodes",
Some("text/plain"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::CREATED);
let v = json_str(&resp);
assert_eq!(v["title"].as_str().unwrap(), "Plain");
}
#[tokio::test]
async fn api_orgtext_write_post_uses_query_id_for_org_body() {
let app = build_router(setup_state());
let body = b"* From org\n".to_vec();
let (status, _, resp) = send(
app,
"POST",
"/nodes?id=org-import-1",
Some("text/org"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::CREATED);
let v = json_str(&resp);
assert_eq!(v["id"].as_str().unwrap(), "org-import-1");
}
#[tokio::test]
async fn api_orgtext_write_post_strips_kb_metadata_drawer() {
let app = build_router(setup_state());
let body = b":PROPERTIES:\n:ID: stale-id\n:CREATED: t0\n:END:\n* Heading\nbody\n".to_vec();
let (status, _, resp) = send(
app,
"POST",
"/nodes",
Some("text/org"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::CREATED);
let v = json_str(&resp);
let id = v["id"].as_str().unwrap();
assert!(uuid::Uuid::parse_str(id).is_ok());
let blocks = v["document"]["blocks"].as_array().unwrap();
assert_eq!(blocks.len(), 1);
assert!(
blocks[0].get("Heading").is_some(),
"drawer leaked into AST: {v}"
);
}
#[tokio::test]
async fn api_orgtext_write_put_400_on_id_mismatch() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "n", &sample_doc()).unwrap();
}
let app = build_router(state);
let body = b":PROPERTIES:\n:ID: other-node\n:END:\n* Heading\n".to_vec();
let (status, _, _) = send(
app,
"PUT",
"/nodes/n",
Some("text/org"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn api_orgtext_write_put_accepts_matching_drawer_id() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "n", &sample_doc()).unwrap();
}
let app = build_router(state);
let body = b":PROPERTIES:\n:ID: n\n:END:\n* Updated\n".to_vec();
let (status, _, resp) = send(
app,
"PUT",
"/nodes/n",
Some("text/org"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&resp);
assert_eq!(v["title"].as_str().unwrap(), "Updated");
let blocks = v["document"]["blocks"].as_array().unwrap();
assert!(blocks[0].get("Heading").is_some(), "drawer leaked: {v}");
}
#[tokio::test]
async fn api_orgtext_write_put_parses_org_body() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "n", &sample_doc()).unwrap();
}
let app = build_router(state);
let body = b"* Replaced\nbody\n".to_vec();
let (status, _, resp) = send(
app,
"PUT",
"/nodes/n",
Some("text/org"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&resp);
assert_eq!(v["title"].as_str().unwrap(), "Replaced");
}
#[tokio::test]
async fn api_orgtext_write_put_400_on_invalid_utf8() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "n", &sample_doc()).unwrap();
}
let app = build_router(state);
let body = vec![0xff, 0xfe, 0xfd];
let (status, _, _) = send(
app,
"PUT",
"/nodes/n",
Some("text/org"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn api_orgtext_write_post_400_on_invalid_utf8() {
let app = build_router(setup_state());
let body = vec![0xff, 0xfe, 0xfd];
let (status, _, _) = send(
app,
"POST",
"/nodes",
Some("text/org"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn api_orgtext_write_response_post_returns_org_under_org_accept() {
let app = build_router(setup_state());
let body = b"* Hello\nworld\n".to_vec();
let (status, headers, resp) = send(
app,
"POST",
"/nodes",
Some("text/org"),
Some("text/org"),
body,
)
.await;
assert_eq!(status, StatusCode::CREATED);
let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(ct.starts_with("text/org"), "content-type was {ct}");
let text = String::from_utf8(resp).unwrap();
assert!(!text.starts_with('{'));
assert!(text.contains("Hello"));
}
#[tokio::test]
async fn api_orgtext_write_response_put_returns_org_under_org_accept() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "n", &sample_doc()).unwrap();
}
let app = build_router(state);
let body = serde_json::to_vec(&UpdateNodeRequest {
document: Document {
blocks: vec![Block::Heading {
level: 1,
title: Title("PutOrg".into()),
tags: vec![],
children: vec![],
}],
},
})
.unwrap();
let (status, headers, resp) = send(
app,
"PUT",
"/nodes/n",
Some("application/json"),
Some("text/org"),
body,
)
.await;
assert_eq!(status, StatusCode::OK);
let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(ct.starts_with("text/org"), "content-type was {ct}");
let text = String::from_utf8(resp).unwrap();
assert!(!text.starts_with('{'));
assert!(text.contains("PutOrg"));
}
#[tokio::test]
async fn api_orgtext_write_response_put_returns_json_under_json_accept() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "n", &sample_doc()).unwrap();
}
let app = build_router(state);
let body = b"* Title\n".to_vec();
let (status, headers, resp) = send(
app,
"PUT",
"/nodes/n",
Some("text/org"),
Some("application/json"),
body,
)
.await;
assert_eq!(status, StatusCode::OK);
let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(ct.starts_with("application/json"), "content-type was {ct}");
let v = json_str(&resp);
assert_eq!(v["title"].as_str().unwrap(), "Title");
}
#[tokio::test]
async fn search_endpoint_shape_returns_node_summary_array() {
let state = setup_state();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(
&conn,
"match-1",
&Document {
blocks: vec![Block::Heading {
level: 1,
title: Title("Rust Programming".into()),
tags: vec![],
children: vec![Block::Paragraph {
inlines: vec![Inline::Plain("systems language".into())],
}],
}],
},
)
.unwrap();
}
let app = build_router(state);
let (status, headers, body) = send(
app,
"GET",
"/search?q=programming",
None,
Some("application/json"),
vec![],
)
.await;
assert_eq!(status, StatusCode::OK);
let ct = headers.get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(ct.starts_with("application/json"), "content-type was {ct}");
let v = json_str(&body);
let arr = v.as_array().expect("response is a JSON array");
assert_eq!(arr.len(), 1);
let item = &arr[0];
let obj = item.as_object().expect("array entries are objects");
let mut keys: Vec<&String> = obj.keys().collect();
keys.sort();
assert_eq!(
keys,
vec![&"id".to_string(), &"title".to_string()],
"exactly id+title — no extra fields"
);
assert_eq!(item["id"].as_str().unwrap(), "match-1");
assert_eq!(item["title"].as_str().unwrap(), "Rust Programming");
}
#[tokio::test]
async fn search_endpoint_shape_empty_query_returns_empty_array() {
let app = build_router(setup_state());
let (status, _, body) = send(
app,
"GET",
"/search?q=",
None,
Some("application/json"),
vec![],
)
.await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&body);
assert!(v.as_array().unwrap().is_empty());
}
#[tokio::test]
async fn search_endpoint_shape_no_q_param_returns_empty_array() {
let app = build_router(setup_state());
let (status, _, body) = send(
app,
"GET",
"/search",
None,
Some("application/json"),
vec![],
)
.await;
assert_eq!(status, StatusCode::OK);
let v = json_str(&body);
assert!(v.as_array().unwrap().is_empty());
}
}