AkurAI Build
Menu

Public repository / 796c2f22cc110ffe57e2bb53ebf4ddc47f4ca77e

AkurAI-Framework / crates/cli/src/collections_api.rs

Latest change caebd6669c276c9c4dd88ff423984ed8df65d490 - feat(cli): auto-generated REST API from backend/collections.toml by Ólafur Búi Ólafsson

//! The auto-generated REST API: `backend/collections.toml` → live CRUD endpoints.
//!
//! Declare collections in `backend/collections.toml` and this module mounts a
//! full REST surface for each one under `/api/collections/<name>/records`, with
//! validation, substring `?search`, and — when an embedding endpoint is
//! configured — semantic `?search`. No handwritten endpoints; the schema is the
//! single source of truth.
//!
//! The whole thing is additive: if `backend/collections.toml` is absent, the
//! server behaves exactly as it did before (zero collections, no new routes).
//!
//! ## Storage layout
//!
//! - `data/collections.db` — one B+tree shared by every collection (the
//!   [`akurai_collections::Store`]).
//! - `data/embeddings.db` — a separate B+tree of `<name>:<id_be8>` →
//!   `encode(vector)` rows, written best-effort on create/update and removed on
//!   delete. Embedding never blocks or fails a write.
//!
//! ## Embedding config (both optional)
//!
//! - `AKURAI_EMBED_URL` — an OpenAI-compatible `/v1/embeddings` endpoint. When
//!   unset, `?search` falls back to substring matching. `https://` is rejected
//!   by [`akurai_vector::embed`] (no TLS); use a plain-HTTP endpoint.
//! - `AKURAI_EMBED_MODEL` — the embedding model name (defaults to
//!   `embeddinggemma`).

use std::path::Path;
use std::sync::{Arc, Mutex};

use akurai_collections::{meta, CollError, Collection, Field, FieldKind, Store};
use akurai_http::Method;
use akurai_json::Value;
use akurai_storage::BTree;

/// The default embedding model when `AKURAI_EMBED_MODEL` is unset.
const DEFAULT_EMBED_MODEL: &str = "embeddinggemma";

/// Top-k cap for semantic search when no `?limit` is supplied.
const DEFAULT_SEARCH_K: usize = 20;

/// The live auto-API: the parsed schema plus its shared storage handles.
pub struct CollectionsApi {
    collections: Vec<Collection>,
    store: Arc<Mutex<Store>>,
    embeddings: Arc<Mutex<BTree>>,
    embed_url: Option<String>,
    embed_model: String,
}

impl CollectionsApi {
    /// Build the API for a project rooted at `project_root` (the directory that
    /// holds `backend/`, `frontend/`, and `data/`).
    ///
    /// Reads `backend/collections.toml` (absent → zero collections), opens the
    /// two B+trees under `data/`, and reads the embed config from the
    /// environment. A malformed `collections.toml` is surfaced as `Err` so the
    /// caller can fail `serve` loudly rather than silently dropping the schema.
    pub fn open(project_root: &Path) -> Result<CollectionsApi, String> {
        let collections = load_collections(project_root)?;
        let data_dir = project_root.join("data");
        std::fs::create_dir_all(&data_dir)
            .map_err(|e| format!("cannot create {}: {e}", data_dir.display()))?;
        let store = Store::open(data_dir.join("collections.db"))
            .map_err(|e| format!("cannot open collections store: {e}"))?;
        let embeddings = BTree::open(data_dir.join("embeddings.db"))
            .map_err(|e| format!("cannot open embeddings store: {e}"))?;
        Ok(CollectionsApi::from_parts(
            collections,
            store,
            embeddings,
            std::env::var("AKURAI_EMBED_URL")
                .ok()
                .filter(|s| !s.is_empty()),
            std::env::var("AKURAI_EMBED_MODEL")
                .ok()
                .filter(|s| !s.is_empty())
                .unwrap_or_else(|| DEFAULT_EMBED_MODEL.to_string()),
        ))
    }

    /// Assemble an API from already-opened parts. Used by [`open`](Self::open)
    /// and by tests that want a temp store.
    pub fn from_parts(
        collections: Vec<Collection>,
        store: Store,
        embeddings: BTree,
        embed_url: Option<String>,
        embed_model: String,
    ) -> CollectionsApi {
        CollectionsApi {
            collections,
            store: Arc::new(Mutex::new(store)),
            embeddings: Arc::new(Mutex::new(embeddings)),
            embed_url,
            embed_model,
        }
    }

    /// The parsed collections, for merging into the `/api/_meta` manifest.
    pub fn collections(&self) -> &[Collection] {
        &self.collections
    }

    /// Route a `/api/collections...` request. `segments` are the path segments
    /// *after* `/api/collections` (so `/api/collections/posts/records/3` arrives
    /// as `["posts", "records", "3"]`). Returns `None` when the path shape is
    /// not a collections route, so the caller falls through to its own handling.
    pub fn dispatch(
        &self,
        method: &Method,
        segments: &[&str],
        query: Option<&str>,
        body: Option<&str>,
    ) -> Option<(u16, Value)> {
        match segments {
            // GET /api/collections  → the manifest.
            [] => match method {
                Method::Get => Some((200, meta(&self.collections))),
                _ => None,
            },
            // /api/collections/<name>/records
            [name, "records"] => self.records_collection(method, name, query, body),
            // /api/collections/<name>/records/<id>
            [name, "records", id] => self.records_item(method, name, id, body),
            _ => None,
        }
    }

    /// `/api/collections/<name>/records` — list/search (GET) or create (POST).
    fn records_collection(
        &self,
        method: &Method,
        name: &str,
        query: Option<&str>,
        body: Option<&str>,
    ) -> Option<(u16, Value)> {
        let Some(coll) = self.collection(name) else {
            return Some(not_found(&format!("unknown collection: {name}")));
        };
        match method {
            Method::Get => {
                let params = QueryParams::parse(query);
                Some(self.list_or_search(coll, &params))
            }
            Method::Post => Some(self.create(coll, body)),
            _ => None,
        }
    }

    /// `/api/collections/<name>/records/<id>` — get/update/delete one record.
    fn records_item(
        &self,
        method: &Method,
        name: &str,
        id: &str,
        body: Option<&str>,
    ) -> Option<(u16, Value)> {
        let Some(coll) = self.collection(name) else {
            return Some(not_found(&format!("unknown collection: {name}")));
        };
        let Ok(id) = id.parse::<u64>() else {
            return Some(bad_request("invalid record id"));
        };
        match method {
            Method::Get => Some(self.get(coll, id)),
            Method::Patch => Some(self.update(coll, id, body)),
            Method::Delete => Some(self.delete(coll, id)),
            _ => None,
        }
    }

    // ---- handlers ---------------------------------------------------------

    fn list_or_search(&self, coll: &Collection, params: &QueryParams) -> (u16, Value) {
        let mut store = self.store.lock().expect("store mutex poisoned");
        let result = match &params.search {
            Some(q) if !q.is_empty() => self.search(&mut store, coll, q, params.limit),
            _ => store.list(coll, params.limit),
        };
        match result {
            Ok(records) => (200, Value::Array(records)),
            Err(e) => coll_error(&e),
        }
    }

    fn create(&self, coll: &Collection, body: Option<&str>) -> (u16, Value) {
        let input = match parse_body(body) {
            Ok(v) => v,
            Err(resp) => return resp,
        };
        let record = {
            let mut store = self.store.lock().expect("store mutex poisoned");
            match store.create(coll, input) {
                Ok(r) => r,
                Err(e) => return coll_error(&e),
            }
        };
        self.index_record(coll, &record);
        (201, record)
    }

    fn get(&self, coll: &Collection, id: u64) -> (u16, Value) {
        let mut store = self.store.lock().expect("store mutex poisoned");
        match store.get(coll, id) {
            Ok(Some(record)) => (200, record),
            Ok(None) => not_found("record not found"),
            Err(e) => coll_error(&e),
        }
    }

    fn update(&self, coll: &Collection, id: u64, body: Option<&str>) -> (u16, Value) {
        let patch = match parse_body(body) {
            Ok(v) => v,
            Err(resp) => return resp,
        };
        let record = {
            let mut store = self.store.lock().expect("store mutex poisoned");
            match store.update(coll, id, patch) {
                Ok(Some(r)) => r,
                Ok(None) => return not_found("record not found"),
                Err(e) => return coll_error(&e),
            }
        };
        self.index_record(coll, &record);
        (200, record)
    }

    fn delete(&self, coll: &Collection, id: u64) -> (u16, Value) {
        let removed = {
            let mut store = self.store.lock().expect("store mutex poisoned");
            match store.delete(coll, id) {
                Ok(removed) => removed,
                Err(e) => return coll_error(&e),
            }
        };
        if removed {
            self.deindex_record(coll, id);
            (204, Value::Null)
        } else {
            not_found("record not found")
        }
    }

    // ---- semantic search + embedding lifecycle ----------------------------

    /// Semantic search when an endpoint is configured, else substring. Always
    /// returns a plain JSON array of records in result order.
    fn search(
        &self,
        store: &mut Store,
        coll: &Collection,
        query: &str,
        limit: Option<usize>,
    ) -> Result<Vec<Value>, CollError> {
        let Some(url) = &self.embed_url else {
            return store.search(coll, query, limit);
        };
        // Embed the query; any failure falls back to substring search.
        let qvec = match akurai_vector::embed(url, &self.embed_model, query) {
            Ok(v) => v,
            Err(e) => {
                eprintln!("collections: query embed failed ({e}); using substring search");
                return store.search(coll, query, limit);
            }
        };
        let k = limit.unwrap_or(DEFAULT_SEARCH_K);
        let candidates = self.load_embeddings(&coll.name);
        let ranked = akurai_vector::rank(&qvec, &candidates, k);
        let mut out = Vec::with_capacity(ranked.len());
        for (id, _score) in ranked {
            if let Some(record) = store.get(coll, id)? {
                out.push(record);
            }
        }
        Ok(out)
    }

    /// Load every stored `(id, vector)` for a collection by prefix-scanning the
    /// embeddings tree.
    fn load_embeddings(&self, name: &str) -> Vec<(u64, Vec<f32>)> {
        let (start, end) = embed_prefix_bounds(name);
        let rows = {
            let mut tree = self.embeddings.lock().expect("embeddings mutex poisoned");
            match tree.range(&start, &end) {
                Ok(rows) => rows,
                Err(e) => {
                    eprintln!("collections: embeddings scan failed ({e})");
                    return Vec::new();
                }
            }
        };
        let mut out = Vec::with_capacity(rows.len());
        for (key, bytes) in rows {
            if let (Some(id), Some(vec)) = (id_from_key(name, &key), akurai_vector::decode(&bytes))
            {
                out.push((id, vec));
            }
        }
        out
    }

    /// Best-effort: embed a record's `embed` Text fields and store the vector.
    /// A missing endpoint, no embed fields, or any embed error is a no-op — the
    /// record is already saved regardless.
    fn index_record(&self, coll: &Collection, record: &Value) {
        let Some(url) = &self.embed_url else {
            return;
        };
        let text = embed_text(coll, record);
        if text.trim().is_empty() {
            return;
        }
        let Some(id) = record.get("id").and_then(Value::as_i64) else {
            return;
        };
        let vec = match akurai_vector::embed(url, &self.embed_model, &text) {
            Ok(v) => v,
            Err(e) => {
                eprintln!(
                    "collections: embed failed for {}:{id} ({e}); record saved",
                    coll.name
                );
                return;
            }
        };
        let key = embed_key(&coll.name, id as u64);
        let bytes = akurai_vector::encode(&vec);
        let mut tree = self.embeddings.lock().expect("embeddings mutex poisoned");
        if let Err(e) = tree.insert(&key, &bytes).and_then(|_| tree.commit()) {
            eprintln!(
                "collections: embeddings write failed for {}:{id} ({e})",
                coll.name
            );
        }
    }

    /// Best-effort: drop a record's stored embedding (called after delete).
    fn deindex_record(&self, coll: &Collection, id: u64) {
        if self.embed_url.is_none() {
            return;
        }
        let key = embed_key(&coll.name, id);
        let mut tree = self.embeddings.lock().expect("embeddings mutex poisoned");
        if let Err(e) = tree.delete(&key).and_then(|_| tree.commit()) {
            eprintln!(
                "collections: embeddings delete failed for {}:{id} ({e})",
                coll.name
            );
        }
    }

    fn collection(&self, name: &str) -> Option<&Collection> {
        self.collections.iter().find(|c| c.name == name)
    }
}

/// Parsed `?search` / `?limit` query parameters.
struct QueryParams {
    search: Option<String>,
    limit: Option<usize>,
}

impl QueryParams {
    fn parse(query: Option<&str>) -> QueryParams {
        let mut search = None;
        let mut limit = None;
        if let Some(q) = query {
            for pair in q.split('&') {
                let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
                match k {
                    "search" => search = Some(percent_decode(v)),
                    "limit" => limit = percent_decode(v).parse::<usize>().ok(),
                    _ => {}
                }
            }
        }
        QueryParams { search, limit }
    }
}

/// Minimal `application/x-www-form-urlencoded` decode for query values: `+` →
/// space and `%XX` → byte. Invalid escapes are passed through literally.
fn percent_decode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        match bytes[i] {
            b'+' => out.push(b' '),
            b'%' if i + 2 < bytes.len() => {
                let hex = |c: u8| match c {
                    b'0'..=b'9' => Some(c - b'0'),
                    b'a'..=b'f' => Some(c - b'a' + 10),
                    b'A'..=b'F' => Some(c - b'A' + 10),
                    _ => None,
                };
                match (hex(bytes[i + 1]), hex(bytes[i + 2])) {
                    (Some(h), Some(l)) => {
                        out.push(h << 4 | l);
                        i += 2;
                    }
                    _ => out.push(b'%'),
                }
            }
            b => out.push(b),
        }
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

// ---- collections.toml → Vec<Collection> ----------------------------------

/// Read and parse `<project_root>/backend/collections.toml` into collections.
/// An absent file yields `Ok(vec![])`; a malformed file or an unknown field
/// type yields `Err` with the file path and (where available) the line number.
pub fn load_collections(project_root: &Path) -> Result<Vec<Collection>, String> {
    let path = project_root.join("backend").join("collections.toml");
    let text = match std::fs::read_to_string(&path) {
        Ok(text) => text,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
        Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
    };
    parse_collections(&text).map_err(|e| format!("{}: {e}", path.display()))
}

/// Map a `collections.toml` document string to a `Vec<Collection>`. Returns a
/// human-readable error (TOML syntax with line, or an unknown field type).
pub fn parse_collections(toml: &str) -> Result<Vec<Collection>, String> {
    let doc = akurai_toml::parse(toml).map_err(|e| format!("line {}: {}", e.line, e.message))?;

    let mut collections = Vec::new();
    let Some(items) = doc.get("collection").and_then(|v| v.as_array()) else {
        // No `[[collection]]` blocks at all → an empty (but valid) schema.
        return Ok(collections);
    };
    for (i, item) in items.iter().enumerate() {
        let name = item
            .get("name")
            .and_then(|v| v.as_str())
            .ok_or_else(|| format!("collection #{} is missing a string `name`", i + 1))?;
        let mut fields = Vec::new();
        if let Some(field_items) = item.get("field").and_then(|v| v.as_array()) {
            for field in field_items {
                fields.push(parse_field(name, field)?);
            }
        }
        collections.push(Collection::new(name, fields));
    }
    Ok(collections)
}

/// Map one `[[collection.field]]` table to a [`Field`].
fn parse_field(coll: &str, field: &akurai_toml::Value) -> Result<Field, String> {
    let name = field
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or_else(|| format!("collection `{coll}`: a field is missing a string `name`"))?;
    let type_str = field
        .get("type")
        .and_then(|v| v.as_str())
        .ok_or_else(|| format!("collection `{coll}`, field `{name}`: missing `type`"))?;
    let kind = field_kind(type_str).ok_or_else(|| {
        format!(
            "collection `{coll}`, field `{name}`: unknown type `{type_str}` \
             (expected text|int|float|bool)"
        )
    })?;
    let mut f = Field::new(name, kind);
    if field.get("required").and_then(|v| v.as_bool()) == Some(true) {
        f = f.required();
    }
    if field.get("embed").and_then(|v| v.as_bool()) == Some(true) {
        f = f.embed();
    }
    Ok(f)
}

/// Map a TOML `type` string (case-insensitive) to a [`FieldKind`].
fn field_kind(s: &str) -> Option<FieldKind> {
    match s.to_ascii_lowercase().as_str() {
        "text" => Some(FieldKind::Text),
        "int" => Some(FieldKind::Int),
        "float" => Some(FieldKind::Float),
        "bool" => Some(FieldKind::Bool),
        _ => None,
    }
}

// ---- embedding helpers ----------------------------------------------------

/// Concatenate a record's `embed` Text fields (in schema order) into one blob.
fn embed_text(coll: &Collection, record: &Value) -> String {
    let mut parts = Vec::new();
    for field in &coll.fields {
        if field.embed && field.kind == FieldKind::Text {
            if let Some(s) = record.get(&field.name).and_then(Value::as_str) {
                parts.push(s.to_string());
            }
        }
    }
    parts.join("\n")
}

/// The storage key for a record's embedding: `<name>:<id_be8>`.
fn embed_key(name: &str, id: u64) -> Vec<u8> {
    let mut key = format!("{name}:").into_bytes();
    key.extend_from_slice(&id.to_be_bytes());
    key
}

/// Half-open `[start, end)` bounds covering every `<name>:` key. `:` is 0x3a; the
/// upper bound bumps it to 0x3b so the scan stops right after this prefix.
fn embed_prefix_bounds(name: &str) -> (Vec<u8>, Vec<u8>) {
    let start = format!("{name}:").into_bytes();
    let mut end = start.clone();
    *end.last_mut().expect("prefix has a trailing ':'") = b';';
    (start, end)
}

/// Recover the record id from an embeddings key, validating the `<name>:` prefix.
fn id_from_key(name: &str, key: &[u8]) -> Option<u64> {
    let prefix = format!("{name}:");
    let rest = key.strip_prefix(prefix.as_bytes())?;
    let bytes: [u8; 8] = rest.try_into().ok()?;
    Some(u64::from_be_bytes(bytes))
}

// ---- response helpers -----------------------------------------------------

fn parse_body(body: Option<&str>) -> Result<Value, (u16, Value)> {
    let raw = body.unwrap_or("").trim();
    if raw.is_empty() {
        return Ok(Value::Object(vec![]));
    }
    akurai_json::parse(raw).map_err(|e| bad_request(&format!("invalid JSON body: {e}")))
}

fn coll_error(e: &CollError) -> (u16, Value) {
    match e {
        CollError::Validation(_) | CollError::Json(_) => bad_request(&e.to_string()),
        CollError::NotFound => not_found("record not found"),
        CollError::UnknownCollection(c) => not_found(&format!("unknown collection: {c}")),
        // Storage/corruption are server faults, not client errors.
        CollError::Io(_) | CollError::Corrupt(_) => (
            500,
            Value::Object(vec![("error".into(), Value::Str(e.to_string()))]),
        ),
    }
}

fn bad_request(msg: &str) -> (u16, Value) {
    (
        400,
        Value::Object(vec![("error".into(), Value::Str(msg.into()))]),
    )
}

fn not_found(msg: &str) -> (u16, Value) {
    (
        404,
        Value::Object(vec![("error".into(), Value::Str(msg.into()))]),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    /// The scratch base: `CARGO_TARGET_TMPDIR` when present (integration tests),
    /// otherwise a dir under this crate's own `target/` — never `/tmp`. Mirrors
    /// the helper in `auth.rs`.
    fn scratch_base() -> PathBuf {
        match std::env::var_os("CARGO_TARGET_TMPDIR") {
            Some(dir) => PathBuf::from(dir),
            None => PathBuf::from(env!("CARGO_MANIFEST_DIR"))
                .join("target")
                .join("collapi-test-tmp"),
        }
    }

    // ---- load_collections / parse_collections mapping --------------------

    const SAMPLE: &str = "\
[[collection]]
name = \"posts\"

  [[collection.field]]
  name = \"title\"
  type = \"text\"
  required = true

  [[collection.field]]
  name = \"body\"
  type = \"TEXT\"
  embed = true

  [[collection.field]]
  name = \"views\"
  type = \"int\"
";

    #[test]
    fn parses_collections_and_fields() {
        let colls = parse_collections(SAMPLE).expect("valid schema");
        assert_eq!(colls.len(), 1);
        let posts = &colls[0];
        assert_eq!(posts.name, "posts");
        assert_eq!(posts.fields.len(), 3);

        let title = posts.field("title").unwrap();
        assert_eq!(title.kind, FieldKind::Text);
        assert!(title.required);
        assert!(!title.embed);

        let body = posts.field("body").unwrap();
        assert_eq!(body.kind, FieldKind::Text); // case-insensitive `TEXT`
        assert!(body.embed);
        assert!(!body.required);

        let views = posts.field("views").unwrap();
        assert_eq!(views.kind, FieldKind::Int);
    }

    #[test]
    fn empty_doc_is_zero_collections() {
        assert!(parse_collections("").unwrap().is_empty());
    }

    #[test]
    fn unknown_field_type_is_an_error() {
        let toml = "\
[[collection]]
name = \"posts\"
  [[collection.field]]
  name = \"x\"
  type = \"date\"
";
        let err = parse_collections(toml).unwrap_err();
        assert!(err.contains("unknown type"), "got: {err}");
        assert!(err.contains("date"), "got: {err}");
    }

    #[test]
    fn missing_file_yields_empty() {
        let root = scratch_base().join("no-backend-here");
        assert!(load_collections(&root).unwrap().is_empty());
    }

    // ---- dispatch CRUD over a temp store ---------------------------------

    fn temp_api(label: &str) -> CollectionsApi {
        let dir = scratch_base().join(format!("collapi-{label}"));
        std::fs::create_dir_all(&dir).unwrap();
        // Fresh files per test run.
        let _ = std::fs::remove_file(dir.join("c.db"));
        let _ = std::fs::remove_file(dir.join("e.db"));
        let posts = Collection::new(
            "posts",
            vec![
                Field::new("title", FieldKind::Text).required(),
                Field::new("body", FieldKind::Text).embed(),
            ],
        );
        let store = Store::open(dir.join("c.db")).unwrap();
        let embeddings = BTree::open(dir.join("e.db")).unwrap();
        // No embed URL → substring search, no network.
        CollectionsApi::from_parts(vec![posts], store, embeddings, None, "x".into())
    }

    fn body(json: &str) -> Option<&str> {
        Some(json)
    }

    #[test]
    fn crud_and_substring_search() {
        let api = temp_api("crud");

        // create
        let (status, rec) = api
            .dispatch(
                &Method::Post,
                &["posts", "records"],
                None,
                body(r#"{"title":"Hello","body":"a wonderful world"}"#),
            )
            .unwrap();
        assert_eq!(status, 201);
        let id = rec.get("id").and_then(Value::as_i64).unwrap();

        // a second record for search discrimination
        api.dispatch(
            &Method::Post,
            &["posts", "records"],
            None,
            body(r#"{"title":"Other","body":"unrelated text"}"#),
        )
        .unwrap();

        // list → 2 records
        let (status, list) = api
            .dispatch(&Method::Get, &["posts", "records"], None, None)
            .unwrap();
        assert_eq!(status, 200);
        let Value::Array(items) = &list else {
            panic!("list not an array")
        };
        assert_eq!(items.len(), 2);

        // get one
        let (status, got) = api
            .dispatch(
                &Method::Get,
                &["posts", "records", &id.to_string()],
                None,
                None,
            )
            .unwrap();
        assert_eq!(status, 200);
        assert_eq!(got.get("title").and_then(Value::as_str), Some("Hello"));

        // update
        let (status, upd) = api
            .dispatch(
                &Method::Patch,
                &["posts", "records", &id.to_string()],
                None,
                body(r#"{"title":"Hello edited"}"#),
            )
            .unwrap();
        assert_eq!(status, 200);
        assert_eq!(
            upd.get("title").and_then(Value::as_str),
            Some("Hello edited")
        );

        // substring search (endpoint unset → Store::search over Text fields)
        let (status, found) = api
            .dispatch(
                &Method::Get,
                &["posts", "records"],
                Some("search=wonderful"),
                None,
            )
            .unwrap();
        assert_eq!(status, 200);
        let Value::Array(hits) = &found else {
            panic!("search not an array")
        };
        assert_eq!(hits.len(), 1);
        assert_eq!(hits[0].get("id").and_then(Value::as_i64), Some(id));

        // delete → 204, then a re-get is 404
        let (status, _) = api
            .dispatch(
                &Method::Delete,
                &["posts", "records", &id.to_string()],
                None,
                None,
            )
            .unwrap();
        assert_eq!(status, 204);
        let (status, _) = api
            .dispatch(
                &Method::Get,
                &["posts", "records", &id.to_string()],
                None,
                None,
            )
            .unwrap();
        assert_eq!(status, 404);
    }

    #[test]
    fn validation_failure_is_400() {
        let api = temp_api("validation");
        // `title` is required → missing it is a validation error.
        let (status, resp) = api
            .dispatch(
                &Method::Post,
                &["posts", "records"],
                None,
                body(r#"{"body":"no title"}"#),
            )
            .unwrap();
        assert_eq!(status, 400);
        assert!(resp.get("error").is_some());
    }

    #[test]
    fn unknown_collection_is_404() {
        let api = temp_api("unknown");
        let (status, resp) = api
            .dispatch(&Method::Get, &["ghosts", "records"], None, None)
            .unwrap();
        assert_eq!(status, 404);
        assert!(resp.get("error").is_some());
    }

    #[test]
    fn bad_id_is_400() {
        let api = temp_api("badid");
        let (status, _) = api
            .dispatch(
                &Method::Get,
                &["posts", "records", "not-a-number"],
                None,
                None,
            )
            .unwrap();
        assert_eq!(status, 400);
    }

    #[test]
    fn manifest_lists_collections() {
        let api = temp_api("manifest");
        let (status, m) = api.dispatch(&Method::Get, &[], None, None).unwrap();
        assert_eq!(status, 200);
        assert!(m.get("collections").is_some());
    }

    #[test]
    fn non_collections_shape_falls_through() {
        let api = temp_api("shape");
        assert!(api
            .dispatch(&Method::Get, &["posts", "extra", "1", "2"], None, None)
            .is_none());
    }

    #[test]
    fn query_params_parse() {
        let p = QueryParams::parse(Some("search=hello%20world&limit=5"));
        assert_eq!(p.search.as_deref(), Some("hello world"));
        assert_eq!(p.limit, Some(5));
    }

    #[test]
    fn embed_prefix_bounds_isolate_a_collection() {
        let (start, end) = embed_prefix_bounds("posts");
        let key = embed_key("posts", 7);
        assert!(key.as_slice() >= start.as_slice() && key.as_slice() < end.as_slice());
        // A different collection whose name shares a prefix is excluded.
        let other = embed_key("postsx", 7);
        assert!(!(other.as_slice() >= start.as_slice() && other.as_slice() < end.as_slice()));
        assert_eq!(id_from_key("posts", &key), Some(7));
    }
}