Services

Algolia

Algolia's Search API without a client: objects saved, batched in thousands and partly updated, searches with facets, facet search, whole indexes browsed by cursor past the 1000 hits a search stops at, tasks waited on, secured API keys made from a search key with filters and an expiry, and the host list walked in order, a host that fails left alone for two minutes.

Version 1.0.0 · 17 functions · 6 crates

#search#facets#secured-keys#retries

Functions

What use algolia.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use algolia_hosts_down, algolia_save from algolia (Quillo 0.6).

algolia_hosts_down() → list

The hosts this process is leaving alone for now, after they failed.

algolia_save(index: str, object: any) → anyor an error

Saves an object: with an objectID it replaces that one, without one Algolia makes an id. Gives { objectID, taskID }.

algolia_save_many(index: str, objects: list) → anyor an error

Saves many objects, 1000 at a time. Gives { saved, tasks }: wait on the last task to see them all.

algolia_partial(index: str, id: str, fields: any, create: bool) → anyor an error

Changes some attributes of an object, leaving the rest. A value may be an operation, from algolia_op. create makes it when missing; Algolia does so by default, which hides a wrong id, so say which you mean.

algolia_partial_many(index: str, objects: list, create: bool) → anyor an error

Partly updates many objects, each with its objectID, 1000 at a time. create as for algolia_partial.

algolia_op(name: str, value: any) → anyor an error

A built-in operation for a partial update: algolia_op("Increment", 1), or "Decrement", "Add", "Remove", "AddUnique", "IncrementFrom", "IncrementSet".

algolia_get(index: str, id: str, attributes: list) → anyor an error

One object by id; attributes is a list to fetch, or empty for all.

algolia_delete(index: str, id: str) → anyor an error

Deletes an object.

algolia_filter(attribute: str, value: any) → stror an error

A filter clause with its value quoted: algolia_filter("brand", "Acme & Co") is brand:"Acme & Co", a number price=10, true visible:true.

algolia_browse(index: str, params: any, max_items: int) → listor an error

Every object of an index by cursor, past the 1000 hits a search stops at: params adds filters, attributesToRetrieve; max_items caps them (0: all).

algolia_settings(index: str) → anyor an error

An index's settings.

algolia_settings_set(index: str, settings: any) → anyor an error

Changes an index's settings (searchableAttributes, attributesForFaceting, customRanking...). Gives { taskID, updatedAt }.

algolia_wait(index: str, task_id: int, timeout_ms: int) → boolor an error

Waits until a write is searchable, asking less and less often: task_id from a write, timeout_ms how long to wait. An error if it is still not published.

algolia_secured_key(parent_key: str, restrictions: any) → stror an error

A secured key to hand to one browser, made here with no call to Algolia: parent_key a search-only key (never the admin key), restrictions like { filters: "user_id:42", validUntil: unix seconds, restrictIndices: ["orders"] }. The browser cannot widen these: base64 of the hex HMAC-SHA256 of the query text, followed by that text.

algolia_secured_key_read(key: str) → anyor an error

Reads a secured key back: { restrictions, seconds_left }, seconds_left null when it has no validUntil, negative when it is over.

Get it

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

Or with curl:

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

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

main.qwe
use algolia.qweruse algolia_hosts_down, algolia_save from algolia      // Quillo 0.6: only these

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

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

Example

algolia.qwe
// Search through Algolia: objects saved and waited on, searches with facets, partial updates, an// index browsed to the end, a secured key for one visitor, and hosts that fail passed over.////   ALGOLIA_APP_ID=... ALGOLIA_API_KEY=... ALGOLIA_SEARCH_KEY=... quillo run algolia.qwe//   then open /write, /search, /partial, /browse, /keys, /hosts and /failures//// ALGOLIA_HOSTS, comma-separated, replaces Algolia's hosts, as for a test server. Writes need a key// with that right, and a browser only ever gets a secured key made from a search-only one.use "algolia.qwer"route GET /write {    let one = algolia_save("products", { objectID: "mug-1", name: "Blue mug", brand: "Acme & Co", price: 12, tags: ["kitchen"], stock: 5 })?    let auto = algolia_save("products", { name: "No id yet", brand: "Other", price: 3 })?    let many = algolia_save_many("products", map(range(0, 2500), (i) => { objectID: `item-${i}`, name: `Item ${i}`, brand: if i % 2 == 0 { "Acme & Co" } else { "Globex" }, price: i % 50 }))?    {        waited: algolia_wait("products", one.taskID, 5000)?,        all_in: algolia_wait("products", many.tasks[len(many.tasks) - 1], 5000)?,        tasks: len(many.tasks), saved: many.saved,        made_id: auto.objectID != null,        read: algolia_get("products", "mug-1", ["name", "price"])?,        not_map: algolia_save("products", [1]).error,        slow: algolia_wait("products", 9999999, 150).error,    }}route GET /search {    let r = algolia_search("products", "item", { filters: algolia_filter("brand", "Acme & Co")?, facets: ["brand"], hitsPerPage: 5, page: 1 })?    {        hits: len(r.hits), total: r.nbHits, page: r.page, pages: r.nbPages, facets: r.facets,        stats: r.facets_stats.price,        filters: [algolia_filter("brand", "A \"B\"")?, algolia_filter("price", 10)?, algolia_filter("visible", true)?],        facet_search: algolia_facet_search("products", "brand", "ac", {})?,        not_searchable: algolia_facet_search("products", "price", "1", {}).error,    }}route GET /partial {    algolia_save("products", { objectID: "mug-2", name: "Red mug", stock: 5, tags: ["kitchen"] })?    algolia_partial("products", "mug-2", { stock: algolia_op("Increment", 3)?, tags: algolia_op("AddUnique", "sale")?, name: "Red mug, large" }, false)?    let many = algolia_partial_many("products", [{ objectID: "mug-2", stock: 1 }, { objectID: "missing", stock: 1 }], false)?    let read = algolia_get("products", "mug-2", [])?    let del = algolia_delete("products", "mug-2")?    {        name: read.name, stock: read.stock, tags: read.tags, many: many.saved,        kept_out: algolia_get("products", "missing", []).error,        no_id: algolia_partial_many("products", [{ stock: 1 }], false).error,        bad_op: algolia_op("Multiply", 2).error,        deleted: del.taskID != null,        made: algolia_partial("products", "fresh", { stock: 1 }, true)?.objectID,        not_made: algolia_partial("products", "ghost", { stock: 1 }, false)?.objectID,        ghost: algolia_get("products", "ghost", []).error,    }}route GET /browse {    let all = algolia_browse("catalog", {}, 0)?    {        all: len(all), capped: len(algolia_browse("catalog", {}, 1500)?), past_search: algolia_search("catalog", "", { page: 20, hitsPerPage: 100 })?.hits,        settings: algolia_settings_set("catalog", { attributesForFaceting: ["searchable(brand)"], customRanking: ["desc(sales)"] })?.taskID != null,        read: algolia_settings("catalog")?.customRanking,    }}route GET /keys {    let key = algolia_secured_key("parent-search-key", { filters: "user_id:42 AND visible:true", validUntil: 1893456000, restrictIndices: ["orders", "invoices"] })?    let back = algolia_secured_key_read(key)?    {        key: key,        restrictions: back.restrictions,        left: back.seconds_left > 0,        none: algolia_secured_key("p", {}).error,        bad_time: algolia_secured_key("p", { validUntil: "soon" }).error,        junk: algolia_secured_key_read("not base64!").error,    }}route GET /hosts = {    down: map(algolia_hosts_down(), (h) => slice(h, 0, 7)),    still_works: algolia_settings("products")? != null,}route GET /failures = {    missing: algolia_get("products", "nosuch", []).error,    bad_key: algolia_get("forbidden", "x", []).error,    bad_query: algolia_search("products", "x", { filters: "price>" }).error,    no_index: algolia_search("", "x", {}).error,}
$ quillo run algolia.qwe$ curl 'localhost:8080/write'$ curl 'localhost:8080/search'$ curl 'localhost:8080/partial'$ curl 'localhost:8080/browse'$ curl 'localhost:8080/keys'$ curl 'localhost:8080/hosts'$ curl 'localhost:8080/failures'

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

algolia.qwer
//! Algolia's Search API without a client: objects saved, batched in thousands and partly updated, searches with facets, facet search, whole indexes browsed by cursor, tasks waited on, secured API keys made from a search key with filters and an expiry, and the host list walked in order, a host that fails left alone for two minutes.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! hmac = "0.12"//! sha2 = "0.10"//! hex = "0.4"//! base64 = "0.22"//! serde_json = { version = "1", features = ["preserve_order"] }use base64::{engine::general_purpose::STANDARD, Engine};use hmac::{Hmac, Mac};use serde_json::{json, Value};use std::collections::HashMap;use std::sync::Mutex;use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};// ALGOLIA_APP_ID and ALGOLIA_API_KEY sign in. ALGOLIA_HOSTS, comma-separated, replaces the hosts: left// out, reads go to <APP>-dsn.algolia.net and writes to <APP>.algolia.net, then both to// <APP>-1, -2 and -3.algolianet.com.const BATCH: usize = 1000;const DOWN_FOR: Duration = Duration::from_secs(120);fn setting(k: &str) -> Option<String> {    std::env::var(k).ok().filter(|v| !v.is_empty())}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 hosts(write: bool) -> Result<Vec<String>, String> {    if let Some(h) = setting("ALGOLIA_HOSTS") {        return Ok(h.split(',').map(|x| x.trim().trim_end_matches('/').to_string()).filter(|x| !x.is_empty()).collect());    }    let app = setting("ALGOLIA_APP_ID").ok_or("ALGOLIA_APP_ID is not set")?.to_lowercase();    let first = if write { format!("https://{}.algolia.net", app) } else { format!("https://{}-dsn.algolia.net", app) };    let mut all = vec![first];    for i in 1..=3 {        all.push(format!("https://{}-{}.algolianet.com", app, i));    }    Ok(all)}static DOWN: Mutex<Option<HashMap<String, Instant>>> = Mutex::new(None);fn is_down(h: &str) -> bool {    DOWN.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new).get(h).map_or(false, |t| t.elapsed() < DOWN_FOR)}fn mark_down(h: &str) {    DOWN.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new).insert(h.to_string(), Instant::now());}/// The hosts this process is leaving alone for now, after they failed.pub fn algolia_hosts_down() -> Vec<String> {    let mut g = DOWN.lock().unwrap_or_else(|e| e.into_inner());    let mut l: Vec<String> = g.get_or_insert_with(HashMap::new).iter().filter(|(_, t)| t.elapsed() < DOWN_FOR).map(|(h, _)| h.clone()).collect();    l.sort();    l}// A 4xx is an answer: only a host that cannot be reached, times out or breaks is passed over.fn call(method: &str, path: &str, body: Option<&Value>, write: bool) -> Result<Value, String> {    let app = setting("ALGOLIA_APP_ID").ok_or("ALGOLIA_APP_ID is not set")?;    let key = setting("ALGOLIA_API_KEY").ok_or("ALGOLIA_API_KEY is not set")?;    let all = hosts(write)?;    let mut order: Vec<&String> = all.iter().filter(|h| !is_down(h)).collect();    // All down: try them anyway, rather than fail without asking.    if order.is_empty() {        order = all.iter().collect();    }    let agent = ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(2)).timeout(Duration::from_secs(if write { 30 } else { 5 })).build();    let mut tried = vec![];    for h in order {        let req = agent.request(method, &format!("{}{}", h, path)).set("X-Algolia-Application-Id", &app).set("X-Algolia-API-Key", &key).set("Accept", "application/json");        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())?;                return serde_json::from_str(&t).map_err(|_| format!("Algolia's answer is not JSON: {}", t.chars().take(200).collect::<String>()));            }            Err(ureq::Error::Status(c, r)) if c < 500 => {                let t = r.into_string().unwrap_or_default();                let v: Value = serde_json::from_str(&t).unwrap_or(Value::Null);                let m = v["message"].as_str().map(str::to_string).unwrap_or_else(|| t.trim().chars().take(300).collect());                return Err(format!("Algolia {}: {}", c, m));            }            Err(ureq::Error::Status(c, _)) => {                mark_down(h);                tried.push(format!("{} answered {}", h, c));            }            Err(e) => {                mark_down(h);                tried.push(format!("{}: {}", h, e));            }        }    }    Err(format!("no Algolia host answered: {}", tried.join("; ")))}fn idx(index: &str) -> Result<String, String> {    if index.is_empty() {        return Err("an index needs a name".into());    }    Ok(enc(index))}/// Saves an object: with an `objectID` it replaces that one, without one Algolia makes an id. Gives { objectID, taskID }.pub fn algolia_save(index: &str, object: serde_json::Value) -> Result<serde_json::Value, String> {    if !object.is_object() {        return Err("an object must be a map".into());    }    let i = idx(index)?;    match object["objectID"].as_str() {        Some(id) => call("PUT", &format!("/1/indexes/{}/{}", i, enc(id)), Some(&object), true),        None => call("POST", &format!("/1/indexes/{}", i), Some(&object), true),    }}/// Saves many objects, 1000 at a time. Gives { saved, tasks }: wait on the last task to see them all.pub fn algolia_save_many(index: &str, objects: Vec<serde_json::Value>) -> Result<serde_json::Value, String> {    let list: Vec<Value> = objects.iter().map(|o| json!({ "action": if o["objectID"].is_string() { "updateObject" } else { "addObject" }, "body": o })).collect();    batches(index, list)}fn batches(index: &str, requests: Vec<Value>) -> Result<Value, String> {    let path = format!("/1/indexes/{}/batch", idx(index)?);    let mut tasks = vec![];    for chunk in requests.chunks(BATCH) {        let v = call("POST", &path, Some(&json!({ "requests": chunk })), true)?;        tasks.push(v["taskID"].clone());    }    Ok(json!({ "saved": requests.len(), "tasks": tasks }))}/// Changes some attributes of an object, leaving the rest. A value may be an operation, from algolia_op. `create` makes it when missing; Algolia does so by default, which hides a wrong id, so say which you mean.pub fn algolia_partial(index: &str, id: &str, fields: serde_json::Value, create: bool) -> Result<serde_json::Value, String> {    call("POST", &format!("/1/indexes/{}/{}/partial?createIfNotExists={}", idx(index)?, enc(id), create), Some(&fields), true)}/// Partly updates many objects, each with its `objectID`, 1000 at a time. `create` as for algolia_partial.pub fn algolia_partial_many(index: &str, objects: Vec<serde_json::Value>, create: bool) -> Result<serde_json::Value, String> {    let action = if create { "partialUpdateObject" } else { "partialUpdateObjectNoCreate" };    let mut list = vec![];    for (i, o) in objects.iter().enumerate() {        if !o["objectID"].is_string() {            return Err(format!("object {} has no objectID", i));        }        list.push(json!({ "action": action, "body": o }));    }    batches(index, list)}/// A built-in operation for a partial update: `algolia_op("Increment", 1)`, or "Decrement", "Add", "Remove", "AddUnique", "IncrementFrom", "IncrementSet".pub fn algolia_op(name: &str, value: serde_json::Value) -> Result<serde_json::Value, String> {    if !["Increment", "Decrement", "Add", "Remove", "AddUnique", "IncrementFrom", "IncrementSet"].contains(&name) {        return Err(format!("`{}` is not one of Algolia's operations", name));    }    Ok(json!({ "_operation": name, "value": value }))}/// One object by id; `attributes` is a list to fetch, or empty for all.pub fn algolia_get(index: &str, id: &str, attributes: Vec<String>) -> Result<serde_json::Value, String> {    let q = if attributes.is_empty() { String::new() } else { format!("?attributesToRetrieve={}", enc(&attributes.join(","))) };    call("GET", &format!("/1/indexes/{}/{}{}", idx(index)?, enc(id), q), None, false)}/// Deletes an object.pub fn algolia_delete(index: &str, id: &str) -> Result<serde_json::Value, String> {    call("DELETE", &format!("/1/indexes/{}/{}", idx(index)?, enc(id)), None, true)}/// Searches: `params` is Algolia's (`filters`, `facets`, `hitsPerPage`, `page`, `attributesToRetrieve`...). Gives its answer: `hits`, `nbHits`, `page`, `nbPages`, `facets`, `facets_stats`.pub fn algolia_search(index: &str, query: &str, params: serde_json::Value) -> Result<serde_json::Value, String> {    let mut b = if params.is_object() { params } else { json!({}) };    b["query"] = json!(query);    call("POST", &format!("/1/indexes/{}/query", idx(index)?), Some(&b), false)}/// A filter clause with its value quoted: `algolia_filter("brand", "Acme & Co")` is `brand:"Acme & Co"`, a number `price=10`, true `visible:true`.pub fn algolia_filter(attribute: &str, value: serde_json::Value) -> Result<String, String> {    match &value {        Value::String(s) => Ok(format!("{}:\"{}\"", attribute, s.replace('\\', "\\\\").replace('"', "\\\""))),        Value::Number(n) => Ok(format!("{}={}", attribute, n)),        Value::Bool(b) => Ok(format!("{}:{}", attribute, b)),        _ => Err("a filter's value is text, a number or true or false".into()),    }}/// Searches within a facet's values, for "as you type" filters: needs `searchable(attribute)` in the index's `attributesForFaceting`. `params` adds `filters`. Gives [{ value, highlighted, count }].pub fn algolia_facet_search(index: &str, facet: &str, query: &str, params: serde_json::Value) -> Result<serde_json::Value, String> {    let mut b = json!({ "facetQuery": query });    if let Some(m) = params.as_object() {        let p: Vec<String> = m.iter().map(|(k, v)| format!("{}={}", enc(k), enc(&v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())))).collect();        b["params"] = json!(p.join("&"));    }    Ok(call("POST", &format!("/1/indexes/{}/facets/{}/query", idx(index)?, enc(facet)), Some(&b), false)?["facetHits"].clone())}/// Every object of an index by cursor, past the 1000 hits a search stops at: `params` adds `filters`, `attributesToRetrieve`; `max_items` caps them (0: all).pub fn algolia_browse(index: &str, params: serde_json::Value, max_items: i64) -> Result<Vec<serde_json::Value>, String> {    let path = format!("/1/indexes/{}/browse", idx(index)?);    let mut b = if params.is_object() { params } else { json!({}) };    if b.get("hitsPerPage").is_none() {        b["hitsPerPage"] = json!(1000);    }    let mut out = vec![];    loop {        let v = call("POST", &path, Some(&b), false)?;        out.extend(v["hits"].as_array().cloned().unwrap_or_default());        if max_items > 0 && out.len() as i64 >= max_items {            out.truncate(max_items as usize);            return Ok(out);        }        match v["cursor"].as_str() {            Some(c) => b = json!({ "cursor": c }),            None => return Ok(out),        }    }}/// An index's settings.pub fn algolia_settings(index: &str) -> Result<serde_json::Value, String> {    call("GET", &format!("/1/indexes/{}/settings", idx(index)?), None, false)}/// Changes an index's settings (`searchableAttributes`, `attributesForFaceting`, `customRanking`...). Gives { taskID, updatedAt }.pub fn algolia_settings_set(index: &str, settings: serde_json::Value) -> Result<serde_json::Value, String> {    call("PUT", &format!("/1/indexes/{}/settings", idx(index)?), Some(&settings), true)}/// Waits until a write is searchable, asking less and less often: `task_id` from a write, `timeout_ms` how long to wait. An error if it is still not published.pub fn algolia_wait(index: &str, task_id: i64, timeout_ms: i64) -> Result<bool, String> {    let start = Instant::now();    let path = format!("/1/indexes/{}/task/{}", idx(index)?, task_id);    let mut pause = 100u64;    loop {        let v = call("GET", &path, None, false)?;        if v["status"] == "published" {            return Ok(true);        }        if start.elapsed().as_millis() as i64 >= timeout_ms {            return Err(format!("task {} was not published in {} ms", task_id, timeout_ms));        }        std::thread::sleep(Duration::from_millis(pause));        pause = (pause * 3 / 2).min(2000);    }}fn restriction_text(restrictions: &Value) -> Result<String, String> {    let m = restrictions.as_object().ok_or("restrictions must be a map, like { filters, validUntil, restrictIndices }")?;    let mut parts = vec![];    for (k, v) in m {        let t = match v {            Value::String(s) => s.clone(),            Value::Array(a) if a.iter().all(|x| x.is_string()) => a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(","),            o => o.to_string(),        };        parts.push(format!("{}={}", enc(k), enc(&t)));    }    Ok(parts.join("&"))}/// A secured key to hand to one browser, made here with no call to Algolia: `parent_key` a search-only key (never the admin key), `restrictions` like { filters: "user_id:42", validUntil: unix seconds, restrictIndices: ["orders"] }. The browser cannot widen these: base64 of the hex HMAC-SHA256 of the query text, followed by that text.pub fn algolia_secured_key(parent_key: &str, restrictions: serde_json::Value) -> Result<String, String> {    if restrictions.get("validUntil").is_some() && !restrictions["validUntil"].is_i64() {        return Err("validUntil is unix seconds".into());    }    let q = restriction_text(&restrictions)?;    if q.is_empty() {        return Err("a secured key with no restrictions is only the parent key".into());    }    let mut m = Hmac::<sha2::Sha256>::new_from_slice(parent_key.as_bytes()).expect("HMAC takes any key");    m.update(q.as_bytes());    Ok(STANDARD.encode(format!("{}{}", hex::encode(m.finalize().into_bytes()), q)))}/// Reads a secured key back: { restrictions, seconds_left }, `seconds_left` null when it has no validUntil, negative when it is over.pub fn algolia_secured_key_read(key: &str) -> Result<serde_json::Value, String> {    let raw = STANDARD.decode(key.trim()).map_err(|_| "not a secured key: it is not base64".to_string())?;    let text = String::from_utf8(raw).map_err(|_| "not a secured key".to_string())?;    let q = text.get(64..).ok_or("not a secured key: too short")?;    let mut map = serde_json::Map::new();    for pair in q.split('&') {        let (k, v) = pair.split_once('=').unwrap_or((pair, ""));        let mut bytes = vec![];        let b = v.as_bytes();        let mut i = 0;        while i < b.len() {            let pair = if b[i] == b'%' { b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()) } else { None };            if let Some(x) = pair.and_then(|h| u8::from_str_radix(h, 16).ok()) {                bytes.push(x);                i += 3;                continue;            }            bytes.push(b[i]);            i += 1;        }        map.insert(k.to_string(), json!(String::from_utf8_lossy(&bytes)));    }    let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64);    let left = map.get("validUntil").and_then(|v| v.as_str()).and_then(|t| t.parse::<i64>().ok()).map(|t| t - now);    Ok(json!({ "restrictions": map, "seconds_left": left }))}
Thread
27Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.