Menu
Public repository / 7876d4cf55a38441be33963945a26d8c0a5964ba
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, ¶ms))
}
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 ¶ms.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));
}
}