Meilisearch
Meilisearch from a program: indexes, documents and settings written through tasks that are waited on, a failed task turned into its error, searches with filters and facets, and tenant tokens signed so each customer's browser finds only its own documents.
Version 1.0.0 · 13 functions · 5 crates
Functions
What use meilisearch.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use meili_task, meili_wait from meilisearch (Quillo 0.6).
meili_task(uid: int) → anyor an errorThe task's state: { uid, status, type, indexUid, details, error, duration, ... }.
meili_wait(uid: int, timeout_ms: int) → anyor an errorWaits until a task is done, asking more slowly as time goes on, up to timeout_ms. Gives the task when it succeeded; when it failed or was canceled, an error with Meilisearch's own code and message.
meili_create_index(uid: str, primary_key: str) → boolor an errorMakes an index with primary_key ("" lets Meilisearch guess from the first documents) and waits for it. An index that exists is not an error: gives false for it, true when made.
meili_delete_index(uid: str) → boolor an errorDeletes an index and waits; true when it was there.
meili_settings(uid: str, settings: any, wait_ms: int) → anyor an errorChanges an index's settings (searchableAttributes, filterableAttributes, sortableAttributes, rankingRules, synonyms...) and waits for the task. Re-indexing can take a while: wait_ms is how long to wait, 0 not to.
meili_add_documents(uid: str, documents: list, primary_key: str, wait_ms: int) → anyor an errorAdds or replaces documents (a list of maps) and waits up to wait_ms (0 gives the queued task at once). A document with a bad id, or a batch that cannot be indexed, fails here with Meilisearch's reason, although the request itself was accepted.
meili_update_documents(uid: str, documents: list, wait_ms: int) → anyor an errorChanges only the fields given in each document (each needs its primary key), waiting as meili_add_documents does.
meili_document(uid: str, id: str) → anyor an errorA document by id, or null when there is none.
meili_delete_documents(uid: str, ids_or_filter: any, wait_ms: int) → anyor an errorRemoves documents by a list of ids, or by a filter text (the attributes must be filterable), and waits up to wait_ms.
meili_search(uid: str, query: str, options: any) → anyor an errorA search: options as Meilisearch documents them (filter, sort, facets, limit, offset, attributesToHighlight...). Gives { hits, estimatedTotalHits, facetDistribution, processingTimeMs, query, ... }.
meili_search_as(token: str, uid: str, query: str, options: any) → anyor an errorThe same search as a browser with a tenant token would make it: what that tenant sees.
meili_keys() → anyor an errorThe API keys, each { uid, key, name, actions, indexes, expiresAt }.
meili_tenant_token(api_key_uid: str, search_rules: any, expires_at: int) → stror an errorA tenant token: a JWT for the browser that lets it search only what search_rules allows, { "orders": { "filter": "tenant = 7" } }, or { "*": {...} }. api_key_uid is the uid of the key in MEILISEARCH_SEARCH_KEY (see meili_keys); that key signs it. expires_at is a Unix time, 0 for no expiry. No request is made.
Get it
$ quillo install meilisearch # meilisearch.qwer into this folder$ quillo install meilisearch --example # and its example, meilisearch.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/meilisearch.qwer$ curl -O https://quillolang.org/plugins/files/meilisearch.qweThen, in your program, all of it or only what you call:
use meilisearch.qweruse meili_task, meili_wait from meilisearch // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
ureq = "2"base64 = "0.22"hmac = "0.12"sha2 = "0.10"serde_json = { version = "1", features = ["preserve_order"] }Example
// A multi-tenant product search on Meilisearch: documents added and their tasks waited on, a search// with filters and facets, and a tenant token that lets one shop's browser see only its products.//// MEILISEARCH_API_URL=http://localhost:7700 MEILISEARCH_API_KEY=... MEILISEARCH_SEARCH_KEY=... quillo run meilisearch.qwe// then open /setup, /search?q=mug, /tenant, /task-failed and /brokenuse "meilisearch.qwer"route GET /setup = { made: meili_create_index("products", "id")?, again: meili_create_index("products", "id")?, settings: meili_settings("products", { filterableAttributes: ["shop", "color"], sortableAttributes: ["price"] }, 5000)?.status, added: meili_add_documents("products", [ { id: 1, shop: 7, name: "Blue mug", color: "blue", price: 9 }, { id: 2, shop: 7, name: "Red mug", color: "red", price: 12 }, { id: 3, shop: 9, name: "Green mug", color: "green", price: 7 }, ], "", 5000)?.status, updated: meili_update_documents("products", [{ id: 2, price: 11 }], 5000)?.status, doc: meili_document("products", "2")?, no_doc: meili_document("products", "99")?,}route GET /search ? { q: str } { let r = meili_search("products", q, { filter: "color != green", sort: ["price:asc"], facets: ["color"], limit: 5 })? { names: r.hits | map((h) => h.name), total: r.estimatedTotalHits, colors: r.facetDistribution.color }}// What the browser of shop 7 gets: a token, good for an hour, that only finds shop 7's products,// and the same token with a changed signature.route GET /tenant { let key = meili_keys()? | filter((k) => k.name == "search") let token = meili_tenant_token(key[0].uid, { products: { filter: "shop = 7" } }, int(now() / 1000) + 3600)? let r = meili_search_as(token, "products", "mug", null)? { found: r.hits | map((h) => h.name), forged: meili_search_as(token + "x", "products", "mug", null).error, not_a_uid: meili_tenant_token("my-key", { products: {} }, 0).error, }}// The request is accepted; the failure comes in the task, and is an error here.route GET /task-failed = { error: meili_add_documents("products", [{ id: "not valid!", name: "x" }], "", 5000).error }route GET /broken = { no_index: meili_search("nothing-here", "x", null).error, bad_uid: meili_search("no spaces please", "x", null).error, bad_filter: meili_search("products", "x", { filter: "name = mug" }).error,}$ quillo run meilisearch.qwe$ curl 'localhost:8080/setup'$ curl 'localhost:8080/search?q=mug'$ curl 'localhost:8080/tenant'$ curl 'localhost:8080/task-failed'$ curl 'localhost:8080/broken'It talks to the Redis that REDIS_URL names, else Redis on this machine. The site's test runs it against a stand-in.
Source
//! Meilisearch's API: indexes, documents and settings written through tasks that are waited on, with a failed task turned into its error, searches with filters and facets, keys listed, and tenant tokens signed so that each customer's browser searches only its own documents.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! base64 = "0.22"//! hmac = "0.12"//! sha2 = "0.10"//! serde_json = { version = "1", features = ["preserve_order"] }use base64::Engine;use hmac::{Hmac, Mac};use serde_json::{json, Value};use std::time::{Duration, Instant};// MEILISEARCH_API_URL is the server (http://localhost:7700 when not set), MEILISEARCH_API_KEY the// key sent with each request. MEILISEARCH_SEARCH_KEY signs tenant tokens and, with its uid, gives// them their authority (MEILISEARCH_API_KEY when not set).fn env(k: &str) -> Option<String> { std::env::var(k).ok().filter(|v| !v.is_empty())}fn base() -> String { env("MEILISEARCH_API_URL").unwrap_or_else(|| "http://localhost:7700".into()).trim_end_matches('/').to_string()}fn enc(s: &str) -> String { s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{:02X}", b) }).collect()}fn valid_uid(uid: &str) -> Result<(), String> { if uid.is_empty() || uid.len() > 400 || !uid.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { return Err(format!("{:?} is not an index uid: letters, digits, - and _", uid)); } Ok(())}fn call_with(token: Option<String>, method: &str, path: &str, body: Option<&Value>) -> Result<Value, String> { let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(60)).build(); let mut req = agent.request(method, &format!("{}{}", base(), path)); if let Some(k) = token.or_else(|| env("MEILISEARCH_API_KEY")) { req = req.set("Authorization", &format!("Bearer {}", k)); } let res = match body { Some(b) => req.set("Content-Type", "application/json").send_string(&b.to_string()), None => req.call(), }; match res { Ok(r) => { let t = r.into_string().map_err(|e| e.to_string())?; if t.trim().is_empty() { return Ok(Value::Null); } serde_json::from_str(&t).map_err(|_| format!("Meilisearch's answer is not JSON: {}", t.chars().take(200).collect::<String>())) } Err(ureq::Error::Status(c, r)) => { let t = r.into_string().unwrap_or_default(); let v: Value = serde_json::from_str(&t).unwrap_or(Value::Null); match (v["code"].as_str(), v["message"].as_str()) { (Some(code), Some(m)) => Err(format!("Meilisearch refused this ({}): {}", code, m)), _ => Err(format!("Meilisearch answered {}: {}", c, t.chars().take(300).collect::<String>())), } } Err(e) => Err(format!("cannot reach Meilisearch at {}: {}", base(), e)), }}fn call(method: &str, path: &str, body: Option<&Value>) -> Result<Value, String> { call_with(None, method, path, body)}fn task_id(t: &Value) -> Result<i64, String> { t["taskUid"].as_i64().or(t["uid"].as_i64()).ok_or(format!("the answer names no task: {}", t))}/// The task's state: `{ uid, status, type, indexUid, details, error, duration, ... }`.pub fn meili_task(uid: i64) -> Result<Value, String> { call("GET", &format!("/tasks/{}", uid), None)}/// Waits until a task is done, asking more slowly as time goes on, up to `timeout_ms`. Gives the task when it succeeded; when it failed or was canceled, an error with Meilisearch's own code and message.pub fn meili_wait(uid: i64, timeout_ms: i64) -> Result<Value, String> { let start = Instant::now(); let mut pause = 25u64; loop { let t = meili_task(uid)?; match t["status"].as_str().unwrap_or("") { "succeeded" => return Ok(t), "failed" => return Err(format!("task {} failed ({}): {}", uid, t["error"]["code"].as_str().unwrap_or("unknown"), t["error"]["message"].as_str().unwrap_or(""))), "canceled" => return Err(format!("task {} was canceled", uid)), _ => {} } if start.elapsed().as_millis() as i64 > timeout_ms { return Err(format!("task {} is still {} after {} ms", uid, t["status"].as_str().unwrap_or("?"), timeout_ms)); } std::thread::sleep(Duration::from_millis(pause)); pause = (pause * 2).min(1000); }}fn finished(t: Value, wait_ms: i64) -> Result<Value, String> { let id = task_id(&t)?; if wait_ms <= 0 { return Ok(t); } meili_wait(id, wait_ms)}/// Makes an index with `primary_key` ("" lets Meilisearch guess from the first documents) and waits for it. An index that exists is not an error: gives false for it, true when made.pub fn meili_create_index(uid: &str, primary_key: &str) -> Result<bool, String> { valid_uid(uid)?; let mut b = json!({ "uid": uid }); if !primary_key.is_empty() { b["primaryKey"] = json!(primary_key); } let t = call("POST", "/indexes", Some(&b))?; match meili_wait(task_id(&t)?, 30_000) { Ok(_) => Ok(true), Err(e) if e.contains("index_already_exists") => Ok(false), Err(e) => Err(e), }}/// Deletes an index and waits; true when it was there.pub fn meili_delete_index(uid: &str) -> Result<bool, String> { valid_uid(uid)?; let t = call("DELETE", &format!("/indexes/{}", uid), None)?; match meili_wait(task_id(&t)?, 30_000) { Ok(_) => Ok(true), Err(e) if e.contains("index_not_found") => Ok(false), Err(e) => Err(e), }}/// Changes an index's settings (`searchableAttributes`, `filterableAttributes`, `sortableAttributes`, `rankingRules`, `synonyms`...) and waits for the task. Re-indexing can take a while: `wait_ms` is how long to wait, 0 not to.pub fn meili_settings(uid: &str, settings: serde_json::Value, wait_ms: i64) -> Result<Value, String> { valid_uid(uid)?; finished(call("PATCH", &format!("/indexes/{}/settings", uid), Some(&settings))?, wait_ms)}/// Adds or replaces documents (a list of maps) and waits up to `wait_ms` (0 gives the queued task at once). A document with a bad id, or a batch that cannot be indexed, fails here with Meilisearch's reason, although the request itself was accepted.pub fn meili_add_documents(uid: &str, documents: Vec<serde_json::Value>, primary_key: &str, wait_ms: i64) -> Result<Value, String> { valid_uid(uid)?; let q = if primary_key.is_empty() { String::new() } else { format!("?primaryKey={}", enc(primary_key)) }; finished(call("POST", &format!("/indexes/{}/documents{}", uid, q), Some(&Value::Array(documents)))?, wait_ms)}/// Changes only the fields given in each document (each needs its primary key), waiting as `meili_add_documents` does.pub fn meili_update_documents(uid: &str, documents: Vec<serde_json::Value>, wait_ms: i64) -> Result<Value, String> { valid_uid(uid)?; finished(call("PUT", &format!("/indexes/{}/documents", uid), Some(&Value::Array(documents)))?, wait_ms)}/// A document by id, or null when there is none.pub fn meili_document(uid: &str, id: &str) -> Result<Value, String> { valid_uid(uid)?; match call("GET", &format!("/indexes/{}/documents/{}", uid, enc(id)), None) { Err(e) if e.contains("document_not_found") => Ok(Value::Null), o => o, }}/// Removes documents by a list of ids, or by a `filter` text (the attributes must be filterable), and waits up to `wait_ms`.pub fn meili_delete_documents(uid: &str, ids_or_filter: serde_json::Value, wait_ms: i64) -> Result<Value, String> { valid_uid(uid)?; let t = match &ids_or_filter { Value::Array(_) => call("POST", &format!("/indexes/{}/documents/delete-batch", uid), Some(&ids_or_filter))?, Value::String(f) => call("POST", &format!("/indexes/{}/documents/delete", uid), Some(&json!({ "filter": f })))?, _ => return Err("give a list of ids, or a filter text".into()), }; finished(t, wait_ms)}/// A search: `options` as Meilisearch documents them (`filter`, `sort`, `facets`, `limit`, `offset`, `attributesToHighlight`...). Gives `{ hits, estimatedTotalHits, facetDistribution, processingTimeMs, query, ... }`.pub fn meili_search(uid: &str, query: &str, options: serde_json::Value) -> Result<Value, String> { valid_uid(uid)?; let mut b = if options.is_object() { options } else { json!({}) }; b["q"] = json!(query); call("POST", &format!("/indexes/{}/search", uid), Some(&b))}/// The same search as a browser with a tenant token would make it: what that tenant sees.pub fn meili_search_as(token: &str, uid: &str, query: &str, options: serde_json::Value) -> Result<Value, String> { valid_uid(uid)?; let mut b = if options.is_object() { options } else { json!({}) }; b["q"] = json!(query); call_with(Some(token.to_string()), "POST", &format!("/indexes/{}/search", uid), Some(&b))}/// The API keys, each `{ uid, key, name, actions, indexes, expiresAt }`.pub fn meili_keys() -> Result<Value, String> { Ok(call("GET", "/keys?limit=100", None)?["results"].clone())}/// A tenant token: a JWT for the browser that lets it search only what `search_rules` allows, `{ "orders": { "filter": "tenant = 7" } }`, or `{ "*": {...} }`. `api_key_uid` is the uid of the key in MEILISEARCH_SEARCH_KEY (see `meili_keys`); that key signs it. `expires_at` is a Unix time, 0 for no expiry. No request is made.pub fn meili_tenant_token(api_key_uid: &str, search_rules: serde_json::Value, expires_at: i64) -> Result<String, String> { let hex: Vec<char> = api_key_uid.chars().filter(|c| *c != '-').collect(); if hex.len() != 32 || !hex.iter().all(|c| c.is_ascii_hexdigit()) { return Err(format!("{:?} is not a key's uid: that is the UUID in the key's `uid`, not the key", api_key_uid)); } if !(search_rules.is_object() || search_rules.is_array()) { return Err("searchRules are a map of index to its rules, or a list of index names".into()); } let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64); if expires_at != 0 && expires_at <= now { return Err("the token's expiry is in the past".into()); } let key = env("MEILISEARCH_SEARCH_KEY").or_else(|| env("MEILISEARCH_API_KEY")).ok_or("set MEILISEARCH_SEARCH_KEY, the search key that signs tenant tokens")?; let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; let mut claims = json!({ "searchRules": search_rules, "apiKeyUid": api_key_uid }); if expires_at != 0 { claims["exp"] = json!(expires_at); } let signing = format!("{}.{}", b64.encode(json!({ "alg": "HS256", "typ": "JWT" }).to_string()), b64.encode(claims.to_string())); let mut m = Hmac::<sha2::Sha256>::new_from_slice(key.as_bytes()).expect("HMAC takes any key"); m.update(signing.as_bytes()); Ok(format!("{}.{}", signing, b64.encode(m.finalize().into_bytes())))}Related
typesense.qwer · 1.0.0Typesense from a program: collections, JSONL imports with each refused line named, searches with filters and facets, every page walked, filter values quoted, and scoped search keys made offline so each tenant's browser carries a filter it cannot change.3 views · 9 downloadsElasticsearch elasticsearch.qwer · 1.0.0Elasticsearch and OpenSearch from a program: documents written with optimistic locks, bulk NDJSON with each item's failure named and rejected items sent again, searches with hits flattened, search_after paging, and whole result sets walked under a point in time on either product.4 views · 8 downloadsFull-text search search.qwer · 1.0.0A full-text search index in a folder: add documents, search them with ranking, phrases and highlighted snippets, and delete them, with no search server.8 views · 6 downloadsQdrant qdrant.qwer · 1.0.0Qdrant over REST: collections, points written in batches and waited on, vector search with payload filters written the short way, scrolling through everything by its offset, and payloads counted, changed and deleted by id or filter.4 views · 8 downloadsVector search vectors.qwer · 1.0.0Nearest neighbours among embeddings, by collection: "more like this" and semantic search by cosine similarity, saved to a file and loaded back.5 views · 8 downloadsThread 0 posts
On Meilisearch: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.