ANW-26 HTTP /query markdown-merge mode
Accept: text/markdown on /query concatenates matched note bodies into one markdown document, each fragment preceded by an HTML-comment source marker. Adds __anw-order (frontmatter-key ordering, asc/desc, missing-key-last) and __anw-kind (homogeneity guard, 400 on a mixed merge). Both evaluate over the full matched set before __anw-limit truncates. Merge mode uses its own body-cloning projection; the JSON path is unchanged (no body clone).
This commit is contained in:
parent
0c461be698
commit
03cc12e640
6 changed files with 529 additions and 1 deletions
98
src/http.rs
98
src/http.rs
|
|
@ -168,12 +168,30 @@ async fn get_health(State(state): State<HttpState>) -> Response {
|
|||
.expect("static response")
|
||||
}
|
||||
|
||||
async fn get_query(State(state): State<HttpState>, OriginalUri(uri): OriginalUri) -> Response {
|
||||
async fn get_query(
|
||||
State(state): State<HttpState>,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let raw = uri.query().unwrap_or("");
|
||||
let parsed = match crate::query::parse(raw) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return bad_request(&e.to_string()).into_response(),
|
||||
};
|
||||
|
||||
// `Accept: text/markdown` selects merge mode: concatenated note bodies
|
||||
// ([ANW-26]). Without it, `/query` returns the JSON list as before.
|
||||
if wants_markdown(&headers) {
|
||||
return match crate::query::execute_merge(&state.store, &parsed) {
|
||||
Ok(body) => Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(CONTENT_TYPE, "text/markdown; charset=utf-8")
|
||||
.body(Body::from(body))
|
||||
.expect("static response"),
|
||||
Err(crate::query::MergeError::KindGuard(msg)) => bad_request(&msg).into_response(),
|
||||
};
|
||||
}
|
||||
|
||||
let resp = crate::query::execute(&state.store, &parsed, rfc3339_z);
|
||||
let bytes = serde_json::to_vec(&resp).expect("query response serializes");
|
||||
Response::builder()
|
||||
|
|
@ -648,6 +666,84 @@ mod tests {
|
|||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// Note builder with caller-chosen frontmatter and body, for the
|
||||
/// [ANW-26] merge-mode tests.
|
||||
fn note_fm(path: &str, fm: BTreeMap<String, Value>, body: &str) -> Note {
|
||||
let raw = format!("---\n# fm\n---\n{body}");
|
||||
let etag = format!("\"{}\"", blake3::hash(raw.as_bytes()).to_hex());
|
||||
let size = raw.len() as u64;
|
||||
Note {
|
||||
path: path.into(),
|
||||
frontmatter: fm,
|
||||
body: body.into(),
|
||||
raw_bytes: raw.into_bytes(),
|
||||
last_modified: DateTime::from_timestamp(0, 0).unwrap(),
|
||||
etag,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_fm(kind: &str) -> BTreeMap<String, Value> {
|
||||
let mut fm = BTreeMap::new();
|
||||
fm.insert("kind".into(), Value::String(kind.into()));
|
||||
fm
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_accept_markdown_merges_bodies() {
|
||||
let (r, _) = router_with(vec![note("a.md", "Body A."), note("b.md", "Body B.")]);
|
||||
let req = Request::get("/query")
|
||||
.header(ACCEPT, "text/markdown")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = send(r, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.headers().get(CONTENT_TYPE).unwrap(),
|
||||
"text/markdown; charset=utf-8"
|
||||
);
|
||||
let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
|
||||
let text = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
assert_eq!(
|
||||
text,
|
||||
"<!-- source: a.md -->\nBody A.\n\n<!-- source: b.md -->\nBody B."
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_without_accept_is_json_no_body() {
|
||||
let (r, _) = router_with(vec![note("a.md", "Body A.")]);
|
||||
let req = Request::get("/query").body(Body::empty()).unwrap();
|
||||
let resp = send(r, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.headers().get(CONTENT_TYPE).unwrap(),
|
||||
"application/json"
|
||||
);
|
||||
let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["total"], 1);
|
||||
assert!(v["results"][0].get("body").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_markdown_kind_guard_returns_400() {
|
||||
let (r, _) = router_with(vec![
|
||||
note_fm("a.md", kind_fm("PDR"), "a"),
|
||||
note_fm("b.md", kind_fm("ADR"), "b"),
|
||||
]);
|
||||
let req = Request::get("/query?__anw-kind=kind")
|
||||
.header(ACCEPT, "text/markdown")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = send(r, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
|
||||
let text = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
assert!(text.contains("PDR"));
|
||||
assert!(text.contains("ADR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_path_normalizations() {
|
||||
assert_eq!(resolve_path("Notes/a.md").unwrap(), "Notes/a.md");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue