Stores

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

#search#meilisearch#multi-tenant#tokens

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 error

The task's state: { uid, status, type, indexUid, details, error, duration, ... }.

meili_wait(uid: int, timeout_ms: int) → anyor an error

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.

meili_create_index(uid: str, primary_key: str) → boolor an error

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.

meili_delete_index(uid: str) → boolor an error

Deletes an index and waits; true when it was there.

meili_settings(uid: str, settings: any, wait_ms: int) → anyor an error

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.

meili_add_documents(uid: str, documents: list, primary_key: str, wait_ms: int) → anyor an error

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.

meili_update_documents(uid: str, documents: list, wait_ms: int) → anyor an error

Changes 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 error

A document by id, or null when there is none.

meili_delete_documents(uid: str, ids_or_filter: any, wait_ms: int) → anyor an error

Removes documents by a list of ids, or by a filter text (the attributes must be filterable), and waits up to wait_ms.

meili_search_as(token: str, uid: str, query: str, options: any) → anyor an error

The same search as a browser with a tenant token would make it: what that tenant sees.

meili_keys() → anyor an error

The API keys, each { uid, key, name, actions, indexes, expiresAt }.

meili_tenant_token(api_key_uid: str, search_rules: any, expires_at: int) → stror an error

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.

Get it

$ quillo install meilisearch               # meilisearch.qwer into this folder$ quillo install meilisearch --example     # and its example, meilisearch.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/meilisearch.qwer$ curl -O https://quillolang.org/plugins/files/meilisearch.qwe

Then, in your program, all of it or only what you call:

main.qwe
use meilisearch.qweruse meili_task, meili_wait from meilisearch      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
ureq = "2"base64 = "0.22"hmac = "0.12"sha2 = "0.10"serde_json = { version = "1", features = ["preserve_order"] }

Example

meilisearch.qwe
// 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.qwer
//! 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())))}
Thread
28Useful? Like it, or add to its thread below.

Thread 0 posts

On Meilisearch: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.