2496 lines
98 KiB
Rust
2496 lines
98 KiB
Rust
use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS};
|
|
use reqwest::{Client as HttpClient, Method, RequestBuilder, StatusCode};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Map, Value};
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use super::{http_client_builder, ColumnInfo};
|
|
use crate::db::document_result::DocumentQueryResult;
|
|
use crate::models::connection::DatabaseConnectionInfo;
|
|
use crate::types::QueryResult;
|
|
|
|
const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
|
|
.add(b' ')
|
|
.add(b'"')
|
|
.add(b'#')
|
|
.add(b'%')
|
|
.add(b'/')
|
|
.add(b'<')
|
|
.add(b'>')
|
|
.add(b'?')
|
|
.add(b'[')
|
|
.add(b'\\')
|
|
.add(b']')
|
|
.add(b'^')
|
|
.add(b'`')
|
|
.add(b'{')
|
|
.add(b'|')
|
|
.add(b'}');
|
|
|
|
const QUERY_VALUE_ENCODE_SET: &AsciiSet = &PATH_SEGMENT_ENCODE_SET.add(b'&').add(b'=').add(b'+');
|
|
|
|
const INDEX_PAGE_SIZE: u64 = 1_000;
|
|
const FIELD_SAMPLE_SIZE: u64 = 100;
|
|
const TASK_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
|
const TASK_WAIT_TIMEOUT: Duration = Duration::from_secs(60);
|
|
const REST_RESPONSE_MAX_BYTES: usize = 8 * 1024 * 1024;
|
|
|
|
#[derive(Clone)]
|
|
pub struct MeilisearchClient {
|
|
http: HttpClient,
|
|
base_url: String,
|
|
api_key: Option<String>,
|
|
/// Per-index metadata cache so repeat searches stay single-request. Shared
|
|
/// across clones of the pooled client; evicted when the index is deleted.
|
|
index_info_cache: Arc<Mutex<HashMap<String, IndexInfoResponse>>>,
|
|
}
|
|
|
|
impl MeilisearchClient {
|
|
pub fn new(
|
|
url: &str,
|
|
api_key: Option<&str>,
|
|
tls_enabled: bool,
|
|
url_params: Option<&str>,
|
|
timeout: Duration,
|
|
) -> Result<Self, String> {
|
|
let mut builder = http_client_builder(timeout);
|
|
if meilisearch_accept_invalid_certs(tls_enabled, url_params) {
|
|
builder = builder.danger_accept_invalid_certs(true);
|
|
}
|
|
let http = builder.build().map_err(|error| format!("Meilisearch HTTP client error: {error}"))?;
|
|
let api_key = api_key.map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
|
|
Ok(Self {
|
|
http,
|
|
base_url: url.trim_end_matches('/').to_string(),
|
|
api_key,
|
|
index_info_cache: Arc::new(Mutex::new(HashMap::new())),
|
|
})
|
|
}
|
|
|
|
pub fn new_for_config(
|
|
url: &str,
|
|
api_key: Option<&str>,
|
|
tls_enabled: bool,
|
|
url_params: Option<&str>,
|
|
external_config: Option<&Value>,
|
|
timeout: Duration,
|
|
) -> Result<Self, String> {
|
|
let base_url = meilisearch_base_url(url, external_config)?;
|
|
Self::new(&base_url, api_key, tls_enabled, url_params, timeout)
|
|
}
|
|
|
|
fn request(&self, method: Method, path: &str) -> RequestBuilder {
|
|
let request = self.http.request(method, format!("{}{}", self.base_url, path));
|
|
match self.api_key.as_deref() {
|
|
Some(api_key) => request.bearer_auth(api_key),
|
|
None => request,
|
|
}
|
|
}
|
|
|
|
fn get(&self, path: &str) -> RequestBuilder {
|
|
self.request(Method::GET, path)
|
|
}
|
|
|
|
fn post(&self, path: &str) -> RequestBuilder {
|
|
self.request(Method::POST, path)
|
|
}
|
|
|
|
fn delete(&self, path: &str) -> RequestBuilder {
|
|
self.request(Method::DELETE, path)
|
|
}
|
|
}
|
|
|
|
fn meilisearch_base_url(url: &str, external_config: Option<&Value>) -> Result<String, String> {
|
|
let url = url.trim_end_matches('/');
|
|
let Some(raw_base_path) = external_config
|
|
.and_then(Value::as_object)
|
|
.and_then(|config| config.get("basePath").or_else(|| config.get("base_path")))
|
|
.and_then(Value::as_str)
|
|
else {
|
|
return Ok(url.to_string());
|
|
};
|
|
|
|
let input = raw_base_path.trim();
|
|
if input.is_empty() || input == "/" {
|
|
return Ok(url.to_string());
|
|
}
|
|
if input.contains(['?', '#']) {
|
|
return Err("Meilisearch base path cannot contain a query or fragment".to_string());
|
|
}
|
|
|
|
let segments = input.split('/').filter(|segment| !segment.is_empty()).collect::<Vec<_>>();
|
|
if segments.iter().any(|segment| matches!(*segment, "." | "..")) {
|
|
return Err("Meilisearch base path cannot contain '.' or '..' segments".to_string());
|
|
}
|
|
Ok(format!("{url}/{}", segments.join("/")))
|
|
}
|
|
|
|
fn meilisearch_accept_invalid_certs(tls_enabled: bool, url_params: Option<&str>) -> bool {
|
|
tls_enabled
|
|
&& url_params.is_some_and(|params| {
|
|
params.trim_start_matches('?').split('&').any(|pair| {
|
|
let (key, value) = pair.split_once('=').unwrap_or((pair, "true"));
|
|
matches!(key.trim().to_ascii_lowercase().as_str(), "insecure" | "tls_insecure" | "accept_invalid_certs")
|
|
&& matches!(value.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on")
|
|
})
|
|
})
|
|
}
|
|
|
|
fn encode_path_segment(value: &str) -> String {
|
|
utf8_percent_encode(value, PATH_SEGMENT_ENCODE_SET).to_string()
|
|
}
|
|
|
|
async fn response_json(response: reqwest::Response, context: &str) -> Result<Value, String> {
|
|
let status = response.status();
|
|
let body = response.text().await.map_err(|error| format!("Meilisearch response error: {error}"))?;
|
|
if !status.is_success() {
|
|
return Err(meilisearch_error(context, status, &body));
|
|
}
|
|
if body.trim().is_empty() {
|
|
return Ok(Value::Null);
|
|
}
|
|
serde_json::from_str(&body).map_err(|error| format!("Meilisearch parse error: {error}"))
|
|
}
|
|
|
|
fn meilisearch_error(context: &str, status: StatusCode, body: &str) -> String {
|
|
let detail = serde_json::from_str::<Value>(body)
|
|
.ok()
|
|
.and_then(|value| value.get("message").and_then(Value::as_str).map(str::to_string))
|
|
.unwrap_or_else(|| body.trim().to_string());
|
|
if detail.is_empty() {
|
|
format!("Meilisearch {context} failed with HTTP {}", status.as_u16())
|
|
} else {
|
|
format!("Meilisearch {context} failed with HTTP {}: {detail}", status.as_u16())
|
|
}
|
|
}
|
|
|
|
pub async fn test_connection(client: &MeilisearchClient, _timeout: Duration) -> Result<(), String> {
|
|
let health = client.get("/health").send().await.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
response_json(health, "health check").await?;
|
|
|
|
let indexes = client
|
|
.get("/indexes?offset=0&limit=1")
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
response_json(indexes, "index access check").await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn database_connection_info(client: &MeilisearchClient) -> Result<DatabaseConnectionInfo, String> {
|
|
let response =
|
|
client.get("/version").send().await.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let value = response_json(response, "version lookup").await?;
|
|
Ok(DatabaseConnectionInfo {
|
|
product_name: Some("Meilisearch".to_string()),
|
|
product_version: value
|
|
.get("pkgVersion")
|
|
.or_else(|| value.get("version"))
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string),
|
|
driver_name: Some("Meilisearch HTTP API".to_string()),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct IndexInfoResponse {
|
|
uid: String,
|
|
primary_key: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct IndexListResponse {
|
|
results: Vec<IndexInfoResponse>,
|
|
total: u64,
|
|
offset: u64,
|
|
limit: u64,
|
|
}
|
|
|
|
async fn index_info(client: &MeilisearchClient, index: &str) -> Result<IndexInfoResponse, String> {
|
|
if let Some(cached) = client.index_info_cache.lock().unwrap().get(index).cloned() {
|
|
return Ok(cached);
|
|
}
|
|
let response = client
|
|
.get(&format!("/indexes/{}", encode_path_segment(index)))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let value = response_json(response, "index lookup").await?;
|
|
let info: IndexInfoResponse =
|
|
serde_json::from_value(value).map_err(|error| format!("Meilisearch index parse error: {error}"))?;
|
|
client.index_info_cache.lock().unwrap().insert(index.to_string(), info.clone());
|
|
Ok(info)
|
|
}
|
|
|
|
pub async fn list_indexes(client: &MeilisearchClient) -> Result<Vec<String>, String> {
|
|
let mut offset = 0;
|
|
let mut names = Vec::new();
|
|
loop {
|
|
let response = client
|
|
.get(&format!("/indexes?offset={offset}&limit={INDEX_PAGE_SIZE}"))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let value = response_json(response, "index listing").await?;
|
|
let page: IndexListResponse =
|
|
serde_json::from_value(value).map_err(|error| format!("Meilisearch index list parse error: {error}"))?;
|
|
names.extend(page.results.into_iter().map(|index| index.uid));
|
|
let next_offset = page.offset.saturating_add(page.limit);
|
|
if next_offset >= page.total || page.limit == 0 {
|
|
break;
|
|
}
|
|
offset = next_offset;
|
|
}
|
|
names.sort();
|
|
names.dedup();
|
|
Ok(names)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct DocumentsResponse {
|
|
results: Vec<Value>,
|
|
total: u64,
|
|
}
|
|
|
|
fn meilisearch_fetch_body(
|
|
offset: u64,
|
|
limit: u64,
|
|
filter: Option<&str>,
|
|
sort: Option<&str>,
|
|
primary_key: Option<&str>,
|
|
) -> Result<Map<String, Value>, String> {
|
|
let mut body = Map::new();
|
|
body.insert("offset".to_string(), Value::Number(offset.into()));
|
|
body.insert("limit".to_string(), Value::Number(limit.into()));
|
|
body.insert("fields".to_string(), Value::Array(vec![Value::String("*".to_string())]));
|
|
if let Some(filter) = meilisearch_filter_from_request(filter, primary_key)? {
|
|
body.insert("filter".to_string(), filter);
|
|
}
|
|
if let Some(sort) = meilisearch_sort_from_request(sort, primary_key)? {
|
|
body.insert("sort".to_string(), Value::Array(sort.into_iter().map(Value::String).collect()));
|
|
}
|
|
Ok(body)
|
|
}
|
|
|
|
async fn fetch_documents_value(
|
|
client: &MeilisearchClient,
|
|
index: &str,
|
|
offset: u64,
|
|
limit: u64,
|
|
filter: Option<&str>,
|
|
sort: Option<&str>,
|
|
) -> Result<(DocumentsResponse, Option<String>), String> {
|
|
let index_info = index_info(client, index).await?;
|
|
let body = meilisearch_fetch_body(offset, limit, filter, sort, index_info.primary_key.as_deref())?;
|
|
|
|
let response = client
|
|
.post(&format!("/indexes/{}/documents/fetch", encode_path_segment(&index_info.uid)))
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let value = response_json(response, "document fetch").await?;
|
|
let documents =
|
|
serde_json::from_value(value).map_err(|error| format!("Meilisearch document parse error: {error}"))?;
|
|
Ok((documents, index_info.primary_key))
|
|
}
|
|
|
|
pub async fn find_documents(
|
|
client: &MeilisearchClient,
|
|
index: &str,
|
|
skip: u64,
|
|
limit: i64,
|
|
filter: Option<&str>,
|
|
sort: Option<&str>,
|
|
) -> Result<DocumentQueryResult, String> {
|
|
let limit = u64::try_from(limit.max(0)).unwrap_or(0);
|
|
let (result, primary_key) = fetch_documents_value(client, index, skip, limit, filter, sort).await?;
|
|
let documents = result
|
|
.results
|
|
.into_iter()
|
|
.map(|document| inject_document_identity(document, primary_key.as_deref()))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
Ok(DocumentQueryResult {
|
|
documents,
|
|
raw_documents: None,
|
|
extended_documents: None,
|
|
total: result.total,
|
|
total_is_exact: true,
|
|
next_cursor: None,
|
|
})
|
|
}
|
|
|
|
pub async fn fetch_document_page(
|
|
client: &MeilisearchClient,
|
|
index: &str,
|
|
offset: u64,
|
|
limit: u64,
|
|
filter: Option<&str>,
|
|
sort: Option<&str>,
|
|
) -> Result<MeilisearchDocumentPage, String> {
|
|
let (result, _) = fetch_documents_value(client, index, offset, limit, filter, sort).await?;
|
|
Ok(MeilisearchDocumentPage {
|
|
documents_json: result.results.into_iter().map(|document| document.to_string()).collect(),
|
|
total: result.total,
|
|
})
|
|
}
|
|
|
|
pub async fn get_columns(client: &MeilisearchClient, index: &str) -> Result<Vec<ColumnInfo>, String> {
|
|
let (result, primary_key) = fetch_documents_value(client, index, 0, FIELD_SAMPLE_SIZE, None, None).await?;
|
|
let mut fields = BTreeMap::<String, FieldType>::new();
|
|
for document in result.results {
|
|
collect_field_types("", &document, &mut fields);
|
|
}
|
|
if let Some(primary_key) = primary_key.as_deref() {
|
|
fields.entry(primary_key.to_string()).or_insert(FieldType::Unknown);
|
|
}
|
|
Ok(fields
|
|
.into_iter()
|
|
.map(|(name, field_type)| ColumnInfo {
|
|
is_primary_key: primary_key.as_deref() == Some(name.as_str()),
|
|
name,
|
|
data_type: field_type.label().to_string(),
|
|
resolved_schema: None,
|
|
is_nullable: true,
|
|
column_default: None,
|
|
is_unique: false,
|
|
extra: None,
|
|
comment: None,
|
|
numeric_precision: None,
|
|
numeric_scale: None,
|
|
character_maximum_length: None,
|
|
enum_values: None,
|
|
character_set: None,
|
|
collation: None,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum FieldType {
|
|
Unknown,
|
|
Null,
|
|
Boolean,
|
|
Number,
|
|
String,
|
|
Object,
|
|
Array,
|
|
Mixed,
|
|
}
|
|
|
|
impl FieldType {
|
|
fn from_value(value: &Value) -> Self {
|
|
match value {
|
|
Value::Null => Self::Null,
|
|
Value::Bool(_) => Self::Boolean,
|
|
Value::Number(_) => Self::Number,
|
|
Value::String(_) => Self::String,
|
|
Value::Array(_) => Self::Array,
|
|
Value::Object(_) => Self::Object,
|
|
}
|
|
}
|
|
|
|
fn merge(self, other: Self) -> Self {
|
|
match (self, other) {
|
|
(Self::Unknown | Self::Null, value) | (value, Self::Unknown | Self::Null) => value,
|
|
(left, right) if left == right => left,
|
|
_ => Self::Mixed,
|
|
}
|
|
}
|
|
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Unknown => "dynamic",
|
|
Self::Null => "null",
|
|
Self::Boolean => "boolean",
|
|
Self::Number => "number",
|
|
Self::String => "string",
|
|
Self::Object => "object",
|
|
Self::Array => "array",
|
|
Self::Mixed => "mixed",
|
|
}
|
|
}
|
|
}
|
|
|
|
fn collect_field_types(prefix: &str, value: &Value, fields: &mut BTreeMap<String, FieldType>) {
|
|
let Value::Object(map) = value else {
|
|
return;
|
|
};
|
|
for (name, value) in map {
|
|
let path = if prefix.is_empty() { name.clone() } else { format!("{prefix}.{name}") };
|
|
let field_type = FieldType::from_value(value);
|
|
fields.entry(path.clone()).and_modify(|current| *current = current.merge(field_type)).or_insert(field_type);
|
|
if value.is_object() {
|
|
collect_field_types(&path, value, fields);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn inject_document_identity(document: Value, primary_key: Option<&str>) -> Result<Value, String> {
|
|
let mut document =
|
|
document.as_object().cloned().ok_or_else(|| "Meilisearch returned a non-object document".to_string())?;
|
|
if let Some(primary_key) = primary_key {
|
|
if let Some(id) = document.remove(primary_key) {
|
|
document.insert("_id".to_string(), id);
|
|
}
|
|
}
|
|
Ok(Value::Object(document))
|
|
}
|
|
|
|
fn search_hit(hit: Value, primary_key: Option<&str>, highlight: bool, ranking_score: bool) -> MeilisearchSearchHit {
|
|
let id_json = primary_key.and_then(|primary_key| hit.get(primary_key)).map(Value::to_string);
|
|
let mut document = hit;
|
|
let mut formatted = None;
|
|
let mut ranking = None;
|
|
if let Some(object) = document.as_object_mut() {
|
|
// Hoist response metadata only when the request asked for it; without
|
|
// the flag a same-named key is a legitimate user field and stays put.
|
|
if highlight {
|
|
formatted = object.remove("_formatted");
|
|
}
|
|
if ranking_score {
|
|
ranking = object.remove("_rankingScore");
|
|
}
|
|
}
|
|
MeilisearchSearchHit {
|
|
id_json,
|
|
document_json: document.to_string(),
|
|
formatted_json: formatted.map(|value| value.to_string()),
|
|
ranking_score_json: ranking.map(|value| value.to_string()),
|
|
}
|
|
}
|
|
|
|
fn parse_document_object(doc_json: &str) -> Result<Map<String, Value>, String> {
|
|
let value: Value =
|
|
serde_json::from_str(doc_json).map_err(|error| format!("Invalid Meilisearch document JSON: {error}"))?;
|
|
value.as_object().cloned().ok_or_else(|| "Meilisearch document must be a JSON object".to_string())
|
|
}
|
|
|
|
const STRING_ID_PREFIX: &str = "__dbx_meilisearch_string_id__";
|
|
|
|
fn decoded_identity(id: &str) -> Value {
|
|
if let Some(encoded) = id.strip_prefix(STRING_ID_PREFIX) {
|
|
if let Ok(Value::String(value)) = serde_json::from_str::<Value>(encoded) {
|
|
return Value::String(value);
|
|
}
|
|
}
|
|
match serde_json::from_str::<Value>(id) {
|
|
Ok(value @ (Value::String(_) | Value::Number(_))) => value,
|
|
_ => Value::String(id.to_string()),
|
|
}
|
|
}
|
|
|
|
fn identity_path(id: &str) -> String {
|
|
value_to_id(&decoded_identity(id))
|
|
}
|
|
|
|
async fn submit_documents(
|
|
client: &MeilisearchClient,
|
|
index: &str,
|
|
method: Method,
|
|
documents: Vec<Value>,
|
|
) -> Result<(), String> {
|
|
let response = client
|
|
.request(method, &format!("/indexes/{}/documents", encode_path_segment(index)))
|
|
.json(&documents)
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let task = task_from_response(response, "document write").await?;
|
|
wait_for_task(client, task.task_uid).await
|
|
}
|
|
|
|
pub async fn insert_document(client: &MeilisearchClient, index: &str, doc_json: &str) -> Result<String, String> {
|
|
let index_info = index_info(client, index).await?;
|
|
let mut document = parse_document_object(doc_json)?;
|
|
let metadata_id = document.remove("_id");
|
|
if let (Some(primary_key), Some(id)) = (index_info.primary_key.as_deref(), metadata_id) {
|
|
document.insert(primary_key.to_string(), id);
|
|
}
|
|
submit_documents(client, index, Method::POST, vec![Value::Object(document.clone())]).await?;
|
|
Ok(index_info
|
|
.primary_key
|
|
.and_then(|name| document.get(&name).cloned())
|
|
.map(|value| value_to_id(&value))
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
pub async fn update_document(client: &MeilisearchClient, index: &str, id: &str, doc_json: &str) -> Result<u64, String> {
|
|
let index_info = index_info(client, index).await?;
|
|
let primary_key =
|
|
index_info.primary_key.ok_or_else(|| format!("Meilisearch index '{}' has no primary key", index_info.uid))?;
|
|
let mut document = parse_document_object(doc_json)?;
|
|
// `_id` is only the dbx browse alias when it matches the target identity; a
|
|
// genuine user field with a different value must survive the update.
|
|
let is_identity_alias =
|
|
document.get("_id").map(|value| value_to_id(value) == value_to_id(&decoded_identity(id))).unwrap_or(false);
|
|
if is_identity_alias {
|
|
document.remove("_id");
|
|
}
|
|
document.insert(primary_key, decoded_identity(id));
|
|
// Meilisearch uses POST for full replacement and PUT for partial updates.
|
|
// DBX sends the complete edited document so removed fields must disappear.
|
|
submit_documents(client, index, Method::POST, vec![Value::Object(document)]).await?;
|
|
Ok(1)
|
|
}
|
|
|
|
/// Fetch the canonical stored document by identity. Search hits may be shaped
|
|
/// by `displayedAttributes` and search options, so edits must round-trip
|
|
/// through this record instead of a hit payload.
|
|
pub async fn get_document(client: &MeilisearchClient, index: &str, id: &str) -> Result<String, String> {
|
|
let response = client
|
|
.get(&format!(
|
|
"/indexes/{}/documents/{}?fields=*",
|
|
encode_path_segment(index),
|
|
encode_path_segment(&identity_path(id))
|
|
))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let document = response_json(response, "document lookup").await?;
|
|
if !document.is_object() {
|
|
return Err("Meilisearch returned a non-object document".to_string());
|
|
}
|
|
Ok(document.to_string())
|
|
}
|
|
|
|
pub async fn delete_document(client: &MeilisearchClient, index: &str, id: &str) -> Result<u64, String> {
|
|
let response = client
|
|
.delete(&format!(
|
|
"/indexes/{}/documents/{}",
|
|
encode_path_segment(index),
|
|
encode_path_segment(&identity_path(id))
|
|
))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let task = task_from_response(response, "document deletion").await?;
|
|
wait_for_task(client, task.task_uid).await?;
|
|
Ok(1)
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchDocumentUpdate {
|
|
pub id: String,
|
|
pub doc_json: String,
|
|
}
|
|
|
|
pub async fn save_document_batch(
|
|
client: &MeilisearchClient,
|
|
index: &str,
|
|
updates: &[MeilisearchDocumentUpdate],
|
|
delete_ids: &[String],
|
|
inserts: &[String],
|
|
) -> Result<u64, String> {
|
|
let index_info =
|
|
if updates.is_empty() && inserts.is_empty() { None } else { Some(index_info(client, index).await?) };
|
|
|
|
if !updates.is_empty() {
|
|
let index_info = index_info.as_ref().expect("index info is loaded for document writes");
|
|
let primary_key = index_info
|
|
.primary_key
|
|
.as_deref()
|
|
.ok_or_else(|| format!("Meilisearch index '{}' has no primary key", index_info.uid))?;
|
|
let documents = updates
|
|
.iter()
|
|
.map(|update| {
|
|
let mut document = parse_document_object(&update.doc_json)?;
|
|
document.remove("_id");
|
|
document.insert(primary_key.to_string(), decoded_identity(&update.id));
|
|
Ok(Value::Object(document))
|
|
})
|
|
.collect::<Result<Vec<_>, String>>()?;
|
|
submit_documents(client, index, Method::POST, documents).await?;
|
|
}
|
|
|
|
if !delete_ids.is_empty() {
|
|
let ids = delete_ids.iter().map(|id| decoded_identity(id)).collect::<Vec<_>>();
|
|
let response = client
|
|
.post(&format!("/indexes/{}/documents/delete-batch", encode_path_segment(index)))
|
|
.json(&ids)
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let task = task_from_response(response, "document batch deletion").await?;
|
|
wait_for_task(client, task.task_uid).await?;
|
|
}
|
|
|
|
if !inserts.is_empty() {
|
|
let index_info = index_info.as_ref().expect("index info is loaded for document writes");
|
|
let documents = inserts
|
|
.iter()
|
|
.map(|doc_json| {
|
|
let mut document = parse_document_object(doc_json)?;
|
|
let metadata_id = document.remove("_id");
|
|
if let (Some(primary_key), Some(id)) = (index_info.primary_key.as_deref(), metadata_id) {
|
|
document.insert(primary_key.to_string(), id);
|
|
}
|
|
Ok(Value::Object(document))
|
|
})
|
|
.collect::<Result<Vec<_>, String>>()?;
|
|
submit_documents(client, index, Method::POST, documents).await?;
|
|
}
|
|
|
|
Ok((updates.len() + delete_ids.len() + inserts.len()) as u64)
|
|
}
|
|
|
|
fn value_to_id(value: &Value) -> String {
|
|
match value {
|
|
Value::String(value) => value.clone(),
|
|
_ => value.to_string(),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct TaskSummary {
|
|
task_uid: u64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TaskStatus {
|
|
status: String,
|
|
error: Option<Value>,
|
|
}
|
|
|
|
async fn task_from_response(response: reqwest::Response, context: &str) -> Result<TaskSummary, String> {
|
|
let value = response_json(response, context).await?;
|
|
serde_json::from_value(value).map_err(|error| format!("Meilisearch task parse error: {error}"))
|
|
}
|
|
|
|
async fn wait_for_task(client: &MeilisearchClient, task_uid: u64) -> Result<(), String> {
|
|
let deadline = Instant::now() + TASK_WAIT_TIMEOUT;
|
|
loop {
|
|
let response = client
|
|
.get(&format!("/tasks/{task_uid}"))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch task request failed: {error}"))?;
|
|
let value = response_json(response, "task lookup").await?;
|
|
let task: TaskStatus =
|
|
serde_json::from_value(value).map_err(|error| format!("Meilisearch task parse error: {error}"))?;
|
|
match task.status.as_str() {
|
|
"succeeded" => return Ok(()),
|
|
"failed" | "canceled" => {
|
|
let detail = task.error.map(|error| error.to_string()).unwrap_or_else(|| task.status.clone());
|
|
return Err(format!("Meilisearch task {task_uid} {}: {detail}", task.status));
|
|
}
|
|
_ if Instant::now() >= deadline => {
|
|
return Err(format!("Meilisearch task {task_uid} did not finish within 60 seconds"));
|
|
}
|
|
_ => tokio::time::sleep(TASK_POLL_INTERVAL).await,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn meilisearch_filter_from_request(filter: Option<&str>, primary_key: Option<&str>) -> Result<Option<Value>, String> {
|
|
let Some(filter) = filter.map(str::trim).filter(|value| !value.is_empty() && *value != "{}") else {
|
|
return Ok(None);
|
|
};
|
|
// Native Meilisearch filter syntax (e.g. `status = "published" AND rating >= 8`)
|
|
// is passed through; the server validates it. Same trust level as `$meiliFilter`.
|
|
if !filter.starts_with('{') && !filter.starts_with('[') {
|
|
return Ok(Some(Value::String(filter.to_string())));
|
|
}
|
|
let value: Value =
|
|
serde_json::from_str(filter).map_err(|error| format!("Invalid Meilisearch filter JSON: {error}"))?;
|
|
// Native filter arrays (e.g. [["rating > 3"], "status = \"published\""]) are
|
|
// passed through unchanged; the official search API accepts string or array.
|
|
if value.is_array() {
|
|
return Ok(Some(value));
|
|
}
|
|
if let Some(raw) = value.get("$meiliFilter").and_then(Value::as_str) {
|
|
return Ok(Some(Value::String(raw.to_string())));
|
|
}
|
|
filter_expression(&value, primary_key).map(|expression| Some(Value::String(expression)))
|
|
}
|
|
|
|
fn filter_expression(value: &Value, primary_key: Option<&str>) -> Result<String, String> {
|
|
let object = value.as_object().ok_or_else(|| "Meilisearch filter must be a JSON object".to_string())?;
|
|
let mut expressions = Vec::new();
|
|
for (field, condition) in object {
|
|
match field.as_str() {
|
|
"$and" | "$or" => {
|
|
let values =
|
|
condition.as_array().ok_or_else(|| format!("Meilisearch {field} filter must be an array"))?;
|
|
let operator = if field == "$and" { " AND " } else { " OR " };
|
|
let nested = values
|
|
.iter()
|
|
.map(|value| filter_expression(value, primary_key))
|
|
.collect::<Result<Vec<_>, _>>()?
|
|
.into_iter()
|
|
.map(|expression| format!("({expression})"))
|
|
.collect::<Vec<_>>()
|
|
.join(operator);
|
|
if !nested.is_empty() {
|
|
expressions.push(nested);
|
|
}
|
|
}
|
|
"$meiliFilter" => {
|
|
let raw = condition.as_str().ok_or_else(|| "$meiliFilter must be a string".to_string())?;
|
|
if !raw.trim().is_empty() {
|
|
expressions.push(raw.trim().to_string());
|
|
}
|
|
}
|
|
_ => expressions.push(field_filter_expression(field, condition, primary_key)?),
|
|
}
|
|
}
|
|
if expressions.is_empty() {
|
|
return Err("Meilisearch filter is empty".to_string());
|
|
}
|
|
Ok(expressions.into_iter().map(|expression| format!("({expression})")).collect::<Vec<_>>().join(" AND "))
|
|
}
|
|
|
|
fn field_filter_expression(field: &str, condition: &Value, primary_key: Option<&str>) -> Result<String, String> {
|
|
let field = if field == "_id" { primary_key.unwrap_or(field) } else { field };
|
|
validate_filter_field(field)?;
|
|
let Some(operators) = condition.as_object().filter(|value| value.keys().any(|key| key.starts_with('$'))) else {
|
|
return Ok(if condition.is_null() {
|
|
format!("{field} IS NULL")
|
|
} else {
|
|
format!("{field} = {}", filter_value(condition)?)
|
|
});
|
|
};
|
|
|
|
let mut expressions = Vec::new();
|
|
for (operator, value) in operators {
|
|
match operator.as_str() {
|
|
"$options" => {}
|
|
"$ne" if value.is_null() => expressions.push(format!("NOT {field} IS NULL")),
|
|
"$ne" => expressions.push(format!("{field} != {}", filter_value(value)?)),
|
|
"$gt" => expressions.push(format!("{field} > {}", filter_value(value)?)),
|
|
"$gte" => expressions.push(format!("{field} >= {}", filter_value(value)?)),
|
|
"$lt" => expressions.push(format!("{field} < {}", filter_value(value)?)),
|
|
"$lte" => expressions.push(format!("{field} <= {}", filter_value(value)?)),
|
|
"$regex" | "$not" => {
|
|
return Err(format!(
|
|
"Meilisearch contains filters require an experimental server feature; use a raw $meiliFilter only when the target server enables it (field '{field}')"
|
|
));
|
|
}
|
|
_ => return Err(format!("Unsupported Meilisearch filter operator for field '{field}': {operator}")),
|
|
}
|
|
}
|
|
if expressions.is_empty() {
|
|
return Err(format!("Meilisearch filter for field '{field}' is empty"));
|
|
}
|
|
Ok(expressions.into_iter().map(|expression| format!("({expression})")).collect::<Vec<_>>().join(" AND "))
|
|
}
|
|
|
|
fn validate_filter_field(field: &str) -> Result<(), String> {
|
|
if !field.is_empty()
|
|
&& field.split('.').all(|part| {
|
|
!part.is_empty() && part.chars().all(|character| character.is_alphanumeric() || character == '_')
|
|
})
|
|
{
|
|
return Ok(());
|
|
}
|
|
Err(format!("Unsupported Meilisearch filter field: {field}"))
|
|
}
|
|
|
|
fn filter_value(value: &Value) -> Result<String, String> {
|
|
match value {
|
|
Value::String(_) | Value::Number(_) | Value::Bool(_) => Ok(value.to_string()),
|
|
_ => Err("Meilisearch filter values must be strings, numbers, booleans, or null".to_string()),
|
|
}
|
|
}
|
|
|
|
fn meilisearch_sort_from_request(sort: Option<&str>, primary_key: Option<&str>) -> Result<Option<Vec<String>>, String> {
|
|
let Some(sort) = sort.map(str::trim).filter(|value| !value.is_empty() && *value != "{}") else {
|
|
return Ok(None);
|
|
};
|
|
|
|
fn sort_field<'a>(field: &'a str, primary_key: Option<&'a str>) -> Result<&'a str, String> {
|
|
let field = if field == "_id" { primary_key.unwrap_or(field) } else { field };
|
|
validate_filter_field(field)?;
|
|
Ok(field)
|
|
}
|
|
|
|
// The generic document browser sends a JSON object like {"rating":-1}.
|
|
if sort.starts_with('{') || sort.starts_with('[') {
|
|
let value: Value =
|
|
serde_json::from_str(sort).map_err(|error| format!("Invalid Meilisearch sort JSON: {error}"))?;
|
|
let object = value.as_object().ok_or_else(|| "Meilisearch sort must be a JSON object".to_string())?;
|
|
let mut result = Vec::new();
|
|
for (field, direction) in object {
|
|
let field = sort_field(field, primary_key)?;
|
|
let direction = match direction {
|
|
Value::Number(number) if number.as_i64().unwrap_or(1) < 0 => "desc",
|
|
Value::String(value) if value.eq_ignore_ascii_case("desc") => "desc",
|
|
_ => "asc",
|
|
};
|
|
result.push(format!("{field}:{direction}"));
|
|
}
|
|
return Ok((!result.is_empty()).then_some(result));
|
|
}
|
|
|
|
// Native Meilisearch syntax: comma-separated `field:asc|desc` entries, bare field means asc.
|
|
let mut result = Vec::new();
|
|
for entry in sort.split(',') {
|
|
let entry = entry.trim();
|
|
if entry.is_empty() {
|
|
continue;
|
|
}
|
|
let (field, direction) = match entry.split_once(':') {
|
|
Some((field, direction)) => (field.trim(), direction.trim()),
|
|
None => (entry, "asc"),
|
|
};
|
|
let field = sort_field(field, primary_key)?;
|
|
let direction = match direction.to_ascii_lowercase().as_str() {
|
|
"asc" => "asc",
|
|
"desc" => "desc",
|
|
_ => return Err(format!("Unsupported Meilisearch sort direction: {direction}")),
|
|
};
|
|
result.push(format!("{field}:{direction}"));
|
|
}
|
|
Ok((!result.is_empty()).then_some(result))
|
|
}
|
|
|
|
/// Search hit with dbx identity and Meilisearch response metadata kept outside
|
|
/// the document payload: `document` is always the pure user payload, so a real
|
|
/// `_id` / `_formatted` / `_rankingScore` field in the stored document is never
|
|
/// shadowed or stripped. `formatted` / `rankingScore` are hoisted only when the
|
|
/// request actually asked for them.
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchSearchHit {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub id_json: Option<String>,
|
|
pub document_json: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub formatted_json: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub ranking_score_json: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchSearchResult {
|
|
pub hits: Vec<MeilisearchSearchHit>,
|
|
pub total_hits: u64,
|
|
pub processing_time_ms: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchDocumentPage {
|
|
pub documents_json: Vec<String>,
|
|
pub total: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MeilisearchHybrid {
|
|
pub embedder: String,
|
|
pub semantic_ratio: f64,
|
|
}
|
|
|
|
fn meilisearch_search_body(
|
|
q: Option<&str>,
|
|
offset: u64,
|
|
limit: u64,
|
|
filter: Option<&str>,
|
|
sort: Option<&str>,
|
|
primary_key: Option<&str>,
|
|
hybrid: Option<&MeilisearchHybrid>,
|
|
show_ranking_score: bool,
|
|
ranking_score_threshold: Option<f64>,
|
|
) -> Result<Map<String, Value>, String> {
|
|
let mut body = Map::new();
|
|
let q = q.map(str::trim).filter(|value| !value.is_empty());
|
|
if let Some(q) = q {
|
|
body.insert("q".to_string(), Value::String(q.to_string()));
|
|
}
|
|
body.insert("offset".to_string(), Value::Number(offset.into()));
|
|
body.insert("limit".to_string(), Value::Number(limit.into()));
|
|
if let Some(filter) = meilisearch_filter_from_request(filter, primary_key)? {
|
|
body.insert("filter".to_string(), filter);
|
|
}
|
|
if let Some(sort) = meilisearch_sort_from_request(sort, primary_key)? {
|
|
body.insert("sort".to_string(), Value::Array(sort.into_iter().map(Value::String).collect()));
|
|
}
|
|
if let Some(hybrid) = hybrid {
|
|
let embedder = hybrid.embedder.trim();
|
|
if embedder.is_empty() {
|
|
return Err("Meilisearch hybrid search requires an embedder name".to_string());
|
|
}
|
|
let semantic_ratio =
|
|
if hybrid.semantic_ratio.is_finite() { hybrid.semantic_ratio.clamp(0.0, 1.0) } else { 0.5 };
|
|
let ratio =
|
|
serde_json::Number::from_f64(semantic_ratio).ok_or_else(|| "Invalid hybrid semantic ratio".to_string())?;
|
|
let mut hybrid_body = Map::new();
|
|
hybrid_body.insert("embedder".to_string(), Value::String(embedder.to_string()));
|
|
hybrid_body.insert("semanticRatio".to_string(), Value::Number(ratio));
|
|
body.insert("hybrid".to_string(), Value::Object(hybrid_body));
|
|
}
|
|
if show_ranking_score {
|
|
body.insert("showRankingScore".to_string(), Value::Bool(true));
|
|
}
|
|
if let Some(threshold) = ranking_score_threshold.filter(|value| value.is_finite() && *value > 0.0) {
|
|
let threshold = serde_json::Number::from_f64(threshold.clamp(0.0, 1.0))
|
|
.ok_or_else(|| "Invalid ranking score threshold".to_string())?;
|
|
body.insert("rankingScoreThreshold".to_string(), Value::Number(threshold));
|
|
}
|
|
// Highlighting is only meaningful with a query term; asking for `_formatted`
|
|
// on every search would double the response payload for plain browsing.
|
|
if q.is_some() {
|
|
body.insert("attributesToHighlight".to_string(), Value::Array(vec![Value::String("*".to_string())]));
|
|
body.insert("highlightPreTag".to_string(), Value::String("<mark>".to_string()));
|
|
body.insert("highlightPostTag".to_string(), Value::String("</mark>".to_string()));
|
|
}
|
|
Ok(body)
|
|
}
|
|
|
|
fn parse_meilisearch_search_response(
|
|
value: Value,
|
|
primary_key: Option<&str>,
|
|
highlight: bool,
|
|
ranking_score: bool,
|
|
) -> Result<MeilisearchSearchResult, String> {
|
|
let hits = value
|
|
.get("hits")
|
|
.cloned()
|
|
.unwrap_or(Value::Null)
|
|
.as_array()
|
|
.cloned()
|
|
.ok_or_else(|| "Meilisearch search response 'hits' must be an array".to_string())?;
|
|
let total_hits = value
|
|
.get("estimatedTotalHits")
|
|
.or_else(|| value.get("totalHits"))
|
|
.and_then(Value::as_u64)
|
|
.ok_or_else(|| "Meilisearch search response is missing a hit total".to_string())?;
|
|
let processing_time_ms = value.get("processingTimeMs").and_then(Value::as_u64).unwrap_or(0);
|
|
let hits = hits.into_iter().map(|hit| search_hit(hit, primary_key, highlight, ranking_score)).collect();
|
|
Ok(MeilisearchSearchResult { hits, total_hits, processing_time_ms })
|
|
}
|
|
|
|
pub async fn execute_rest_query(client: &MeilisearchClient, input: &str) -> Result<QueryResult, String> {
|
|
let start = Instant::now();
|
|
let input = input.trim();
|
|
if input.is_empty() {
|
|
return Err("Meilisearch REST request is empty".to_string());
|
|
}
|
|
let (request_line, body) = input.split_once('\n').map_or((input, ""), |(line, body)| (line.trim(), body.trim()));
|
|
let (method, path) = parse_rest_request_line(request_line)?;
|
|
let mut request = client.request(method, &path);
|
|
if !body.is_empty() {
|
|
let body: Value =
|
|
serde_json::from_str(body).map_err(|error| format!("Invalid Meilisearch REST JSON body: {error}"))?;
|
|
request = request.json(&body);
|
|
}
|
|
let response = request.send().await.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let status = response.status().as_u16();
|
|
let (body, truncated) = read_bounded_rest_response(response).await?;
|
|
Ok(raw_response_result(status, body, start, truncated))
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn search_documents(
|
|
client: &MeilisearchClient,
|
|
index: &str,
|
|
q: Option<&str>,
|
|
filter: Option<&str>,
|
|
sort: Option<&str>,
|
|
limit: u64,
|
|
offset: u64,
|
|
hybrid: Option<&MeilisearchHybrid>,
|
|
show_ranking_score: bool,
|
|
ranking_score_threshold: Option<f64>,
|
|
) -> Result<MeilisearchSearchResult, String> {
|
|
let index_info = index_info(client, index).await?;
|
|
let body = meilisearch_search_body(
|
|
q,
|
|
offset,
|
|
limit,
|
|
filter,
|
|
sort,
|
|
index_info.primary_key.as_deref(),
|
|
hybrid,
|
|
show_ranking_score,
|
|
ranking_score_threshold,
|
|
)?;
|
|
let response = client
|
|
.post(&format!("/indexes/{}/search", encode_path_segment(&index_info.uid)))
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let value = response_json(response, "document search").await?;
|
|
let highlight = q.map(str::trim).is_some_and(|value| !value.is_empty());
|
|
parse_meilisearch_search_response(value, index_info.primary_key.as_deref(), highlight, show_ranking_score)
|
|
}
|
|
|
|
pub async fn get_index_settings(client: &MeilisearchClient, index: &str) -> Result<Value, String> {
|
|
let response = client
|
|
.get(&format!("/indexes/{}/settings", encode_path_segment(index)))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
response_json(response, "index settings lookup").await
|
|
}
|
|
|
|
pub async fn update_index_settings(client: &MeilisearchClient, index: &str, settings: &Value) -> Result<(), String> {
|
|
let response = client
|
|
.request(Method::PATCH, &format!("/indexes/{}/settings", encode_path_segment(index)))
|
|
.json(settings)
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let task = task_from_response(response, "index settings update").await?;
|
|
wait_for_task(client, task.task_uid).await
|
|
}
|
|
|
|
pub async fn get_index_stats(client: &MeilisearchClient, index: &str) -> Result<Value, String> {
|
|
let response = client
|
|
.get(&format!("/indexes/{}/stats", encode_path_segment(index)))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
response_json(response, "index stats lookup").await
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchIndexOverview {
|
|
pub uid: String,
|
|
pub primary_key: Option<String>,
|
|
pub created_at: Option<String>,
|
|
pub updated_at: Option<String>,
|
|
pub number_of_documents: u64,
|
|
pub is_indexing: bool,
|
|
/// Instance-wide database size in bytes; `None` when the API key cannot read instance stats.
|
|
pub database_size: Option<u64>,
|
|
}
|
|
|
|
pub async fn get_index_overview(client: &MeilisearchClient, index: &str) -> Result<MeilisearchIndexOverview, String> {
|
|
let info = response_json(
|
|
client
|
|
.get(&format!("/indexes/{}", encode_path_segment(index)))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?,
|
|
"index lookup",
|
|
)
|
|
.await?;
|
|
let stats = get_index_stats(client, index).await?;
|
|
// Instance stats require broader key permissions, so keep them best-effort.
|
|
let database_size = match client.get("/stats").send().await {
|
|
Ok(response) => response_json(response, "instance stats lookup")
|
|
.await
|
|
.ok()
|
|
.and_then(|value| value.get("databaseSize").and_then(Value::as_u64)),
|
|
Err(_) => None,
|
|
};
|
|
Ok(MeilisearchIndexOverview {
|
|
uid: info.get("uid").and_then(Value::as_str).unwrap_or(index).to_string(),
|
|
primary_key: info.get("primaryKey").and_then(Value::as_str).map(str::to_string),
|
|
created_at: info.get("createdAt").and_then(Value::as_str).map(str::to_string),
|
|
updated_at: info.get("updatedAt").and_then(Value::as_str).map(str::to_string),
|
|
number_of_documents: stats.get("numberOfDocuments").and_then(Value::as_u64).unwrap_or(0),
|
|
is_indexing: stats.get("isIndexing").and_then(Value::as_bool).unwrap_or(false),
|
|
database_size,
|
|
})
|
|
}
|
|
|
|
pub async fn delete_index(client: &MeilisearchClient, index: &str) -> Result<(), String> {
|
|
let response = client
|
|
.delete(&format!("/indexes/{}", encode_path_segment(index)))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let task = task_from_response(response, "index deletion").await?;
|
|
wait_for_task(client, task.task_uid).await?;
|
|
client.index_info_cache.lock().unwrap().remove(index);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn delete_all_documents(client: &MeilisearchClient, index: &str) -> Result<(), String> {
|
|
let response = client
|
|
.delete(&format!("/indexes/{}/documents", encode_path_segment(index)))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let task = task_from_response(response, "document deletion").await?;
|
|
wait_for_task(client, task.task_uid).await
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchTaskSelector {
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub uids: Vec<u64>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub batch_uids: Vec<u64>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub canceled_by: Vec<u64>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub index_uids: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub statuses: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub types: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub after_enqueued_at: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub before_enqueued_at: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub after_started_at: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub before_started_at: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub after_finished_at: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub before_finished_at: Option<String>,
|
|
}
|
|
|
|
impl MeilisearchTaskSelector {
|
|
fn has_filter(&self) -> bool {
|
|
!self.uids.is_empty()
|
|
|| !self.batch_uids.is_empty()
|
|
|| !self.canceled_by.is_empty()
|
|
|| self.index_uids.iter().any(|value| non_empty(value))
|
|
|| self.statuses.iter().any(|value| non_empty(value))
|
|
|| self.types.iter().any(|value| non_empty(value))
|
|
|| self.after_enqueued_at.as_deref().is_some_and(non_empty)
|
|
|| self.before_enqueued_at.as_deref().is_some_and(non_empty)
|
|
|| self.after_started_at.as_deref().is_some_and(non_empty)
|
|
|| self.before_started_at.as_deref().is_some_and(non_empty)
|
|
|| self.after_finished_at.as_deref().is_some_and(non_empty)
|
|
|| self.before_finished_at.as_deref().is_some_and(non_empty)
|
|
}
|
|
|
|
fn with_allowed_statuses(&self, allowed: &[&str], action: &str) -> Result<Self, String> {
|
|
let mut selector = self.clone();
|
|
selector.index_uids = normalized_strings(&selector.index_uids, false);
|
|
selector.types = normalized_strings(&selector.types, false);
|
|
selector.statuses = normalized_strings(&selector.statuses, true);
|
|
if !selector.has_filter() {
|
|
return Err(format!("Meilisearch task {action} requires at least one explicit filter"));
|
|
}
|
|
selector.statuses = if selector.statuses.is_empty() {
|
|
allowed.iter().map(|status| (*status).to_string()).collect()
|
|
} else {
|
|
allowed
|
|
.iter()
|
|
.filter(|allowed_status| selector.statuses.iter().any(|status| status == **allowed_status))
|
|
.map(|status| (*status).to_string())
|
|
.collect()
|
|
};
|
|
if selector.statuses.is_empty() {
|
|
return Err(format!("Meilisearch task {action} selector does not match any allowed task status"));
|
|
}
|
|
Ok(selector)
|
|
}
|
|
}
|
|
|
|
fn non_empty(value: &str) -> bool {
|
|
!value.trim().is_empty()
|
|
}
|
|
|
|
fn normalized_strings(values: &[String], lowercase: bool) -> Vec<String> {
|
|
let mut normalized = values
|
|
.iter()
|
|
.map(|value| value.trim())
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| if lowercase { value.to_ascii_lowercase() } else { value.to_string() })
|
|
.collect::<Vec<_>>();
|
|
normalized.sort();
|
|
normalized.dedup();
|
|
normalized
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchTask {
|
|
pub uid: u64,
|
|
#[serde(default)]
|
|
pub batch_uid: Option<u64>,
|
|
#[serde(default)]
|
|
pub index_uid: Option<String>,
|
|
pub status: String,
|
|
#[serde(rename = "type")]
|
|
pub task_type: String,
|
|
#[serde(default)]
|
|
pub canceled_by: Option<u64>,
|
|
#[serde(default)]
|
|
pub details: Option<Value>,
|
|
#[serde(default)]
|
|
pub error: Option<Value>,
|
|
#[serde(default)]
|
|
pub duration: Option<String>,
|
|
pub enqueued_at: String,
|
|
#[serde(default)]
|
|
pub started_at: Option<String>,
|
|
#[serde(default)]
|
|
pub finished_at: Option<String>,
|
|
#[serde(flatten)]
|
|
pub extra: BTreeMap<String, Value>,
|
|
}
|
|
|
|
pub fn ensure_task_index(task: &MeilisearchTask, expected_index_uid: Option<&str>) -> Result<(), String> {
|
|
let Some(expected_index_uid) = expected_index_uid else {
|
|
return Ok(());
|
|
};
|
|
match task.index_uid.as_deref() {
|
|
Some(actual_index_uid) if actual_index_uid == expected_index_uid => Ok(()),
|
|
Some(actual_index_uid) => Err(format!(
|
|
"Meilisearch task {} belongs to index '{}', expected index '{}'",
|
|
task.uid, actual_index_uid, expected_index_uid
|
|
)),
|
|
None => Err(format!(
|
|
"Meilisearch task {} is not associated with an index; expected index '{}'",
|
|
task.uid, expected_index_uid
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchTaskPage {
|
|
pub results: Vec<MeilisearchTask>,
|
|
pub total: u64,
|
|
pub limit: u64,
|
|
#[serde(default)]
|
|
pub from: Option<u64>,
|
|
#[serde(default)]
|
|
pub next: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchTaskListInput {
|
|
#[serde(default)]
|
|
pub selector: MeilisearchTaskSelector,
|
|
#[serde(default)]
|
|
pub from: Option<u64>,
|
|
#[serde(default)]
|
|
pub limit: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchEnqueuedTaskSummary {
|
|
pub task_uid: u64,
|
|
#[serde(default)]
|
|
pub index_uid: Option<String>,
|
|
pub status: String,
|
|
#[serde(rename = "type")]
|
|
pub task_type: String,
|
|
pub enqueued_at: String,
|
|
}
|
|
|
|
fn push_csv<T: ToString>(query: &mut Vec<(String, String)>, key: &str, values: &[T]) {
|
|
if !values.is_empty() {
|
|
query.push((key.to_string(), values.iter().map(ToString::to_string).collect::<Vec<_>>().join(",")));
|
|
}
|
|
}
|
|
|
|
fn push_optional(query: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
|
|
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
|
|
query.push((key.to_string(), value.to_string()));
|
|
}
|
|
}
|
|
|
|
fn task_selector_query(selector: &MeilisearchTaskSelector) -> String {
|
|
let mut query = Vec::<(String, String)>::new();
|
|
push_csv(&mut query, "uids", &selector.uids);
|
|
push_csv(&mut query, "batchUids", &selector.batch_uids);
|
|
push_csv(&mut query, "canceledBy", &selector.canceled_by);
|
|
push_csv(&mut query, "indexUids", &selector.index_uids);
|
|
push_csv(&mut query, "statuses", &selector.statuses);
|
|
push_csv(&mut query, "types", &selector.types);
|
|
push_optional(&mut query, "afterEnqueuedAt", selector.after_enqueued_at.as_deref());
|
|
push_optional(&mut query, "beforeEnqueuedAt", selector.before_enqueued_at.as_deref());
|
|
push_optional(&mut query, "afterStartedAt", selector.after_started_at.as_deref());
|
|
push_optional(&mut query, "beforeStartedAt", selector.before_started_at.as_deref());
|
|
push_optional(&mut query, "afterFinishedAt", selector.after_finished_at.as_deref());
|
|
push_optional(&mut query, "beforeFinishedAt", selector.before_finished_at.as_deref());
|
|
query
|
|
.into_iter()
|
|
.map(|(key, value)| format!("{key}={}", utf8_percent_encode(&value, QUERY_VALUE_ENCODE_SET)))
|
|
.collect::<Vec<_>>()
|
|
.join("&")
|
|
}
|
|
|
|
pub async fn get_tasks(
|
|
client: &MeilisearchClient,
|
|
selector: &MeilisearchTaskSelector,
|
|
from: Option<u64>,
|
|
limit: u64,
|
|
) -> Result<MeilisearchTaskPage, String> {
|
|
let mut query = task_selector_query(selector);
|
|
if !query.is_empty() {
|
|
query.push('&');
|
|
}
|
|
query.push_str(&format!("limit={}", limit.clamp(1, 100)));
|
|
if let Some(from) = from {
|
|
query.push_str(&format!("&from={from}"));
|
|
}
|
|
let response = client
|
|
.get(&format!("/tasks?{query}"))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let value = response_json(response, "task listing").await?;
|
|
serde_json::from_value(value).map_err(|error| format!("Meilisearch task list parse error: {error}"))
|
|
}
|
|
|
|
pub async fn get_task(client: &MeilisearchClient, uid: u64) -> Result<MeilisearchTask, String> {
|
|
let response = client
|
|
.get(&format!("/tasks/{uid}"))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let value = response_json(response, "task lookup").await?;
|
|
serde_json::from_value(value).map_err(|error| format!("Meilisearch task parse error: {error}"))
|
|
}
|
|
|
|
async fn mutate_tasks(
|
|
client: &MeilisearchClient,
|
|
selector: &MeilisearchTaskSelector,
|
|
cancel: bool,
|
|
) -> Result<MeilisearchEnqueuedTaskSummary, String> {
|
|
let (selector, action) = if cancel {
|
|
(selector.with_allowed_statuses(&["enqueued", "processing"], "cancellation")?, "cancellation")
|
|
} else {
|
|
(selector.with_allowed_statuses(&["succeeded", "failed", "canceled"], "deletion")?, "deletion")
|
|
};
|
|
let query = task_selector_query(&selector);
|
|
let path = if cancel { format!("/tasks/cancel?{query}") } else { format!("/tasks?{query}") };
|
|
let request = if cancel { client.post(&path) } else { client.delete(&path) };
|
|
let response = request.send().await.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let value = response_json(response, &format!("task {action}")).await?;
|
|
serde_json::from_value(value).map_err(|error| format!("Meilisearch task {action} parse error: {error}"))
|
|
}
|
|
|
|
pub async fn cancel_tasks(
|
|
client: &MeilisearchClient,
|
|
selector: &MeilisearchTaskSelector,
|
|
) -> Result<MeilisearchEnqueuedTaskSummary, String> {
|
|
mutate_tasks(client, selector, true).await
|
|
}
|
|
|
|
pub async fn delete_tasks(
|
|
client: &MeilisearchClient,
|
|
selector: &MeilisearchTaskSelector,
|
|
) -> Result<MeilisearchEnqueuedTaskSummary, String> {
|
|
mutate_tasks(client, selector, false).await
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchKeyCreateInput {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub uid: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub name: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
pub actions: Vec<String>,
|
|
pub indexes: Vec<String>,
|
|
pub expires_at: Value,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchKeyUpdateInput {
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchKeyListItem {
|
|
pub uid: String,
|
|
/// Meilisearch returns the secret in its key-management responses. Keep it
|
|
/// in memory so callers can copy the credential while still rendering the
|
|
/// separately masked value in tables.
|
|
pub key: String,
|
|
pub masked_key: String,
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
pub actions: Vec<String>,
|
|
pub indexes: Vec<String>,
|
|
pub expires_at: Value,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchCreatedKey {
|
|
#[serde(flatten)]
|
|
pub metadata: MeilisearchKeyListItem,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchKeyPage {
|
|
pub results: Vec<MeilisearchKeyListItem>,
|
|
pub total: u64,
|
|
pub offset: u64,
|
|
pub limit: u64,
|
|
}
|
|
|
|
fn mask_key(secret: &str) -> String {
|
|
let chars = secret.chars().collect::<Vec<_>>();
|
|
if chars.len() < 12 {
|
|
return "••••••••".to_string();
|
|
}
|
|
format!("{}…{}", chars[..4].iter().collect::<String>(), chars[chars.len() - 4..].iter().collect::<String>())
|
|
}
|
|
|
|
fn parse_key(value: Value) -> Result<(MeilisearchKeyListItem, String), String> {
|
|
let object = value.as_object().ok_or_else(|| "Meilisearch key response must be an object".to_string())?;
|
|
let string = |field: &str| {
|
|
object
|
|
.get(field)
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string)
|
|
.ok_or_else(|| format!("Missing key field: {field}"))
|
|
};
|
|
let secret = string("key")?;
|
|
let strings = |field: &str| {
|
|
object
|
|
.get(field)
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| format!("Missing key field: {field}"))?
|
|
.iter()
|
|
.map(|value| value.as_str().map(str::to_string).ok_or_else(|| format!("Invalid key field: {field}")))
|
|
.collect::<Result<Vec<_>, String>>()
|
|
};
|
|
Ok((
|
|
MeilisearchKeyListItem {
|
|
uid: string("uid")?,
|
|
key: secret.clone(),
|
|
masked_key: mask_key(&secret),
|
|
name: object.get("name").and_then(Value::as_str).map(str::to_string),
|
|
description: object.get("description").and_then(Value::as_str).map(str::to_string),
|
|
actions: strings("actions")?,
|
|
indexes: strings("indexes")?,
|
|
expires_at: object.get("expiresAt").cloned().unwrap_or(Value::Null),
|
|
created_at: string("createdAt")?,
|
|
updated_at: string("updatedAt")?,
|
|
},
|
|
secret,
|
|
))
|
|
}
|
|
|
|
pub async fn list_keys(client: &MeilisearchClient, offset: u64, limit: u64) -> Result<MeilisearchKeyPage, String> {
|
|
let limit = limit.clamp(1, 100);
|
|
let response = client
|
|
.get(&format!("/keys?offset={offset}&limit={limit}"))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let value = response_json(response, "key listing").await?;
|
|
let object = value.as_object().ok_or_else(|| "Meilisearch key list response must be an object".to_string())?;
|
|
let results = object
|
|
.get("results")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| "Meilisearch key list response is missing results".to_string())?
|
|
.iter()
|
|
.cloned()
|
|
.map(parse_key)
|
|
.map(|result| result.map(|(key, _)| key))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
Ok(MeilisearchKeyPage {
|
|
results,
|
|
total: object.get("total").and_then(Value::as_u64).unwrap_or(0),
|
|
offset: object.get("offset").and_then(Value::as_u64).unwrap_or(offset),
|
|
limit: object.get("limit").and_then(Value::as_u64).unwrap_or(limit),
|
|
})
|
|
}
|
|
|
|
pub async fn get_key(client: &MeilisearchClient, uid: &str) -> Result<MeilisearchKeyListItem, String> {
|
|
let response = client
|
|
.get(&format!("/keys/{}", encode_path_segment(uid)))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let value = response_json(response, "key lookup").await?;
|
|
parse_key(value).map(|(key, _)| key)
|
|
}
|
|
|
|
pub async fn create_key(
|
|
client: &MeilisearchClient,
|
|
input: &MeilisearchKeyCreateInput,
|
|
) -> Result<MeilisearchCreatedKey, String> {
|
|
if input.actions.is_empty() || input.indexes.is_empty() {
|
|
return Err("Meilisearch key actions and indexes must not be empty".to_string());
|
|
}
|
|
let response = client
|
|
.post("/keys")
|
|
.json(input)
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let value = response_json(response, "key creation").await?;
|
|
let (metadata, _key) = parse_key(value)?;
|
|
Ok(MeilisearchCreatedKey { metadata })
|
|
}
|
|
|
|
pub async fn update_key(
|
|
client: &MeilisearchClient,
|
|
uid: &str,
|
|
input: &MeilisearchKeyUpdateInput,
|
|
) -> Result<MeilisearchKeyListItem, String> {
|
|
let response = client
|
|
.request(Method::PATCH, &format!("/keys/{}", encode_path_segment(uid)))
|
|
.json(input)
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
let value = response_json(response, "key update").await?;
|
|
parse_key(value).map(|(key, _)| key)
|
|
}
|
|
|
|
pub async fn delete_key(client: &MeilisearchClient, uid: &str) -> Result<(), String> {
|
|
let response = client
|
|
.delete(&format!("/keys/{}", encode_path_segment(uid)))
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
|
response_json(response, "key deletion").await.map(|_| ())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum MeilisearchOverviewSectionStatus {
|
|
Available,
|
|
Forbidden,
|
|
Unsupported,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchOverviewSection<T> {
|
|
pub status: MeilisearchOverviewSectionStatus,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub data: Option<T>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub message: Option<String>,
|
|
}
|
|
|
|
impl<T> MeilisearchOverviewSection<T> {
|
|
fn available(data: T) -> Self {
|
|
Self { status: MeilisearchOverviewSectionStatus::Available, data: Some(data), message: None }
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchSystemStats {
|
|
pub database_size: Option<u64>,
|
|
pub used_database_size: Option<u64>,
|
|
pub last_update: Option<String>,
|
|
pub index_count: u64,
|
|
pub document_count: u64,
|
|
pub indexing_count: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchIndexSummary {
|
|
pub uid: String,
|
|
pub number_of_documents: u64,
|
|
pub is_indexing: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MeilisearchSystemOverview {
|
|
pub health: MeilisearchOverviewSection<Value>,
|
|
pub version: MeilisearchOverviewSection<Value>,
|
|
pub stats: MeilisearchOverviewSection<MeilisearchSystemStats>,
|
|
pub task_counts: MeilisearchOverviewSection<BTreeMap<String, u64>>,
|
|
pub key_count: MeilisearchOverviewSection<u64>,
|
|
pub top_indexes: MeilisearchOverviewSection<Vec<MeilisearchIndexSummary>>,
|
|
pub refreshed_at: String,
|
|
}
|
|
|
|
async fn overview_value(client: &MeilisearchClient, path: &str, context: &str) -> MeilisearchOverviewSection<Value> {
|
|
let response = match client.get(path).send().await {
|
|
Ok(response) => response,
|
|
Err(error) => {
|
|
return MeilisearchOverviewSection {
|
|
status: MeilisearchOverviewSectionStatus::Error,
|
|
data: None,
|
|
message: Some(format!("Meilisearch request failed: {error}")),
|
|
};
|
|
}
|
|
};
|
|
let status = response.status();
|
|
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
|
return MeilisearchOverviewSection {
|
|
status: MeilisearchOverviewSectionStatus::Forbidden,
|
|
data: None,
|
|
message: Some(format!("Meilisearch {context} is forbidden")),
|
|
};
|
|
}
|
|
if matches!(status, StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED | StatusCode::NOT_IMPLEMENTED) {
|
|
return MeilisearchOverviewSection {
|
|
status: MeilisearchOverviewSectionStatus::Unsupported,
|
|
data: None,
|
|
message: Some(format!("Meilisearch {context} is unsupported")),
|
|
};
|
|
}
|
|
match response_json(response, context).await {
|
|
Ok(value) => MeilisearchOverviewSection::available(value),
|
|
Err(error) => MeilisearchOverviewSection {
|
|
status: MeilisearchOverviewSectionStatus::Error,
|
|
data: None,
|
|
message: Some(error),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn unavailable_stats(
|
|
status: MeilisearchOverviewSectionStatus,
|
|
message: Option<String>,
|
|
) -> (
|
|
MeilisearchOverviewSection<MeilisearchSystemStats>,
|
|
MeilisearchOverviewSection<Vec<MeilisearchIndexSummary>>,
|
|
MeilisearchOverviewSection<Option<String>>,
|
|
) {
|
|
(
|
|
MeilisearchOverviewSection { status: status.clone(), data: None, message: message.clone() },
|
|
MeilisearchOverviewSection { status: status.clone(), data: None, message: message.clone() },
|
|
MeilisearchOverviewSection { status, data: None, message },
|
|
)
|
|
}
|
|
|
|
fn parse_system_stats(
|
|
section: MeilisearchOverviewSection<Value>,
|
|
) -> (
|
|
MeilisearchOverviewSection<MeilisearchSystemStats>,
|
|
MeilisearchOverviewSection<Vec<MeilisearchIndexSummary>>,
|
|
MeilisearchOverviewSection<Option<String>>,
|
|
) {
|
|
let Some(value) = section.data else {
|
|
return unavailable_stats(section.status, section.message);
|
|
};
|
|
let Some(indexes) = value.get("indexes").and_then(Value::as_object) else {
|
|
return unavailable_stats(
|
|
MeilisearchOverviewSectionStatus::Error,
|
|
Some("Meilisearch stats response is missing indexes".to_string()),
|
|
);
|
|
};
|
|
let mut summaries = indexes
|
|
.iter()
|
|
.map(|(uid, stats)| MeilisearchIndexSummary {
|
|
uid: uid.clone(),
|
|
number_of_documents: stats.get("numberOfDocuments").and_then(Value::as_u64).unwrap_or(0),
|
|
is_indexing: stats.get("isIndexing").and_then(Value::as_bool).unwrap_or(false),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let document_count = summaries.iter().map(|summary| summary.number_of_documents).sum();
|
|
let indexing_index_count = summaries.iter().filter(|summary| summary.is_indexing).count() as u64;
|
|
summaries.sort_by(|left, right| {
|
|
right.number_of_documents.cmp(&left.number_of_documents).then_with(|| left.uid.cmp(&right.uid))
|
|
});
|
|
let index_count = summaries.len() as u64;
|
|
summaries.truncate(10);
|
|
let last_update = value.get("lastUpdate").and_then(Value::as_str).map(str::to_string);
|
|
(
|
|
MeilisearchOverviewSection::available(MeilisearchSystemStats {
|
|
database_size: value.get("databaseSize").and_then(Value::as_u64),
|
|
used_database_size: value.get("usedDatabaseSize").and_then(Value::as_u64),
|
|
last_update: last_update.clone(),
|
|
index_count,
|
|
document_count,
|
|
indexing_count: indexing_index_count,
|
|
}),
|
|
MeilisearchOverviewSection::available(summaries),
|
|
MeilisearchOverviewSection::available(last_update),
|
|
)
|
|
}
|
|
|
|
fn parse_count(section: MeilisearchOverviewSection<Value>) -> Result<u64, MeilisearchOverviewSection<Value>> {
|
|
if let Some(total) = section.data.as_ref().and_then(|value| value.get("total")).and_then(Value::as_u64) {
|
|
Ok(total)
|
|
} else if section.data.is_some() {
|
|
Err(MeilisearchOverviewSection {
|
|
status: MeilisearchOverviewSectionStatus::Error,
|
|
data: None,
|
|
message: Some("Meilisearch count response is missing total".to_string()),
|
|
})
|
|
} else {
|
|
Err(section)
|
|
}
|
|
}
|
|
|
|
pub async fn get_system_overview(client: &MeilisearchClient) -> MeilisearchSystemOverview {
|
|
let (health, version, stats, enqueued, processing, succeeded, failed, canceled, key_count) = tokio::join!(
|
|
overview_value(client, "/health", "health lookup"),
|
|
overview_value(client, "/version", "version lookup"),
|
|
overview_value(client, "/stats", "stats lookup"),
|
|
overview_value(client, "/tasks?statuses=enqueued&limit=1", "enqueued task count"),
|
|
overview_value(client, "/tasks?statuses=processing&limit=1", "processing task count"),
|
|
overview_value(client, "/tasks?statuses=succeeded&limit=1", "succeeded task count"),
|
|
overview_value(client, "/tasks?statuses=failed&limit=1", "failed task count"),
|
|
overview_value(client, "/tasks?statuses=canceled&limit=1", "canceled task count"),
|
|
overview_value(client, "/keys?offset=0&limit=1", "key count"),
|
|
);
|
|
let task_sections = [
|
|
("enqueued", enqueued),
|
|
("processing", processing),
|
|
("succeeded", succeeded),
|
|
("failed", failed),
|
|
("canceled", canceled),
|
|
];
|
|
let mut counts = BTreeMap::new();
|
|
let mut task_error = None;
|
|
for (status, section) in task_sections {
|
|
match parse_count(section) {
|
|
Ok(total) => {
|
|
counts.insert(status.to_string(), total);
|
|
}
|
|
Err(section) => {
|
|
task_error = Some(section);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let task_counts = if let Some(section) = task_error {
|
|
MeilisearchOverviewSection { status: section.status, data: None, message: section.message }
|
|
} else {
|
|
MeilisearchOverviewSection::available(counts)
|
|
};
|
|
let key_count = match parse_count(key_count) {
|
|
Ok(total) => MeilisearchOverviewSection::available(total),
|
|
Err(section) => MeilisearchOverviewSection { status: section.status, data: None, message: section.message },
|
|
};
|
|
let (stats, top_indexes, _) = parse_system_stats(stats);
|
|
let refreshed_at = chrono::Utc::now().to_rfc3339();
|
|
MeilisearchSystemOverview { health, version, stats, task_counts, key_count, top_indexes, refreshed_at }
|
|
}
|
|
|
|
async fn read_bounded_rest_response(mut response: reqwest::Response) -> Result<(String, bool), String> {
|
|
let read_limit = REST_RESPONSE_MAX_BYTES.saturating_add(1);
|
|
let capacity = response.content_length().unwrap_or(0).min(read_limit as u64) as usize;
|
|
let mut body = Vec::with_capacity(capacity);
|
|
let mut truncated = response.content_length().is_some_and(|length| length > REST_RESPONSE_MAX_BYTES as u64);
|
|
while body.len() < read_limit {
|
|
let Some(chunk) = response.chunk().await.map_err(|error| format!("Meilisearch response error: {error}"))?
|
|
else {
|
|
break;
|
|
};
|
|
let remaining = read_limit.saturating_sub(body.len());
|
|
if chunk.len() > remaining {
|
|
body.extend_from_slice(&chunk[..remaining]);
|
|
truncated = true;
|
|
break;
|
|
}
|
|
body.extend_from_slice(&chunk);
|
|
if body.len() > REST_RESPONSE_MAX_BYTES {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
}
|
|
Ok((decode_bounded_rest_body(&body), truncated))
|
|
}
|
|
|
|
fn decode_bounded_rest_body(body: &[u8]) -> String {
|
|
let body = &body[..body.len().min(REST_RESPONSE_MAX_BYTES)];
|
|
match std::str::from_utf8(body) {
|
|
Ok(text) => text.to_string(),
|
|
Err(error) if error.error_len().is_none() => String::from_utf8_lossy(&body[..error.valid_up_to()]).into_owned(),
|
|
Err(_) => String::from_utf8_lossy(body).into_owned(),
|
|
}
|
|
}
|
|
|
|
fn parse_rest_request_line(line: &str) -> Result<(Method, String), String> {
|
|
let mut parts = line.split_whitespace();
|
|
let first = parts.next().ok_or_else(|| "Meilisearch REST request is empty".to_string())?;
|
|
let (method, path) = if let Some(path) = parts.next() {
|
|
let method = Method::from_bytes(first.as_bytes()).map_err(|_| format!("Unsupported HTTP method: {first}"))?;
|
|
if !matches!(method, Method::GET | Method::POST | Method::PUT | Method::PATCH | Method::DELETE | Method::HEAD) {
|
|
return Err(format!("Unsupported Meilisearch HTTP method: {first}"));
|
|
}
|
|
(method, path)
|
|
} else {
|
|
(Method::GET, first)
|
|
};
|
|
if parts.next().is_some() {
|
|
return Err("Meilisearch REST request line must be 'METHOD /path'".to_string());
|
|
}
|
|
if !path.starts_with('/') {
|
|
return Err("Meilisearch REST path must start with '/'".to_string());
|
|
}
|
|
if path.contains('#') {
|
|
return Err("Meilisearch REST path must not contain a fragment".to_string());
|
|
}
|
|
let raw_path = path.split_once('?').map_or(path, |(path, _)| path);
|
|
for segment in raw_path.split('/') {
|
|
let decoded = percent_decode_str(segment)
|
|
.decode_utf8()
|
|
.map_err(|_| "Meilisearch REST path contains invalid UTF-8 escaping".to_string())?;
|
|
if matches!(decoded.as_ref(), "." | "..") || decoded.contains(['/', '\\']) {
|
|
return Err("Meilisearch REST path cannot contain traversal segments".to_string());
|
|
}
|
|
}
|
|
Ok((method, path.to_string()))
|
|
}
|
|
|
|
fn raw_response_result(status: u16, body: String, start: Instant, truncated: bool) -> QueryResult {
|
|
let body = if body.trim().is_empty() {
|
|
"null".to_string()
|
|
} else if let Ok(value) = serde_json::from_str::<Value>(&body) {
|
|
serde_json::to_string_pretty(&value).unwrap_or(body)
|
|
} else {
|
|
body
|
|
};
|
|
QueryResult {
|
|
columns: vec!["status".to_string(), "response".to_string()],
|
|
column_types: Vec::new(),
|
|
column_sortables: Vec::new(),
|
|
spatial_columns: Vec::new(),
|
|
spatial_values: Vec::new(),
|
|
rows: vec![vec![Value::Number(status.into()), Value::String(body)]],
|
|
affected_rows: 0,
|
|
execution_time_ms: start.elapsed().as_millis(),
|
|
truncated,
|
|
session_id: None,
|
|
has_more: false,
|
|
elasticsearch_raw_body: None,
|
|
messages: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
decode_bounded_rest_body, decoded_identity, ensure_task_index, filter_expression, identity_path,
|
|
meilisearch_base_url, meilisearch_filter_from_request, meilisearch_search_body, meilisearch_sort_from_request,
|
|
parse_key, parse_meilisearch_search_response, parse_rest_request_line, parse_system_stats, task_selector_query,
|
|
MeilisearchClient, MeilisearchOverviewSection, MeilisearchTask, MeilisearchTaskSelector,
|
|
};
|
|
use reqwest::Method;
|
|
use serde_json::{json, Value};
|
|
use std::time::Duration;
|
|
|
|
#[test]
|
|
fn task_selector_encodes_all_filters_and_cursor_safe_values() {
|
|
let selector = MeilisearchTaskSelector {
|
|
uids: vec![3, 7],
|
|
batch_uids: vec![9],
|
|
canceled_by: vec![11],
|
|
index_uids: vec!["movies / 2026&sort=false".to_string()],
|
|
statuses: vec!["failed".to_string()],
|
|
types: vec!["documentAdditionOrUpdate".to_string()],
|
|
after_enqueued_at: Some("2026-08-22T00:00:00Z".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let query = task_selector_query(&selector);
|
|
assert!(query.contains("uids=3,7"));
|
|
assert!(query.contains("batchUids=9"));
|
|
assert!(query.contains("canceledBy=11"));
|
|
assert!(query.contains("indexUids=movies%20%2F%202026%26sort%3Dfalse"));
|
|
assert!(query.contains("statuses=failed"));
|
|
assert!(query.contains("types=documentAdditionOrUpdate"));
|
|
assert!(!query.contains("reverse="));
|
|
assert!(query.contains("afterEnqueuedAt=2026-08-22T00:00:00Z"));
|
|
}
|
|
|
|
#[test]
|
|
fn task_mutations_require_filters_and_intersect_allowed_statuses() {
|
|
let empty = MeilisearchTaskSelector::default();
|
|
assert!(empty.with_allowed_statuses(&["enqueued", "processing"], "cancellation").is_err());
|
|
let blank = MeilisearchTaskSelector { index_uids: vec![" ".to_string()], ..Default::default() };
|
|
assert!(blank.with_allowed_statuses(&["enqueued", "processing"], "cancellation").is_err());
|
|
|
|
let selector = MeilisearchTaskSelector {
|
|
index_uids: vec!["movies".to_string()],
|
|
statuses: vec!["processing".to_string(), "failed".to_string()],
|
|
..Default::default()
|
|
};
|
|
let cancel = selector.with_allowed_statuses(&["enqueued", "processing"], "cancellation").unwrap();
|
|
assert_eq!(cancel.statuses, vec!["processing"]);
|
|
let delete = selector.with_allowed_statuses(&["succeeded", "failed", "canceled"], "deletion").unwrap();
|
|
assert_eq!(delete.statuses, vec!["failed"]);
|
|
|
|
let succeeded_only = MeilisearchTaskSelector { statuses: vec!["succeeded".to_string()], ..Default::default() };
|
|
assert!(succeeded_only.with_allowed_statuses(&["enqueued", "processing"], "cancellation").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn key_dto_keeps_copyable_secret_and_masked_display_value() {
|
|
let secret = "0123456789abcdef";
|
|
let (item, parsed_secret) = parse_key(json!({
|
|
"uid": "key-uid",
|
|
"key": secret,
|
|
"name": "Search",
|
|
"description": null,
|
|
"actions": ["search"],
|
|
"indexes": ["movies"],
|
|
"expiresAt": null,
|
|
"createdAt": "2026-08-22T00:00:00Z",
|
|
"updatedAt": "2026-08-22T00:00:00Z"
|
|
}))
|
|
.unwrap();
|
|
|
|
assert_eq!(parsed_secret, secret);
|
|
assert_eq!(item.key, secret);
|
|
assert_eq!(item.masked_key, "0123…cdef");
|
|
let wire = serde_json::to_string(&item).unwrap();
|
|
assert!(wire.contains(secret));
|
|
assert!(wire.contains("maskedKey"));
|
|
}
|
|
|
|
#[test]
|
|
fn task_dto_preserves_unknown_fields_and_task_types() {
|
|
let task: MeilisearchTask = serde_json::from_value(json!({
|
|
"uid": 42,
|
|
"batchUid": 7,
|
|
"indexUid": "movies",
|
|
"status": "futureStatus",
|
|
"type": "futureTaskType",
|
|
"details": {"receivedDocuments": 10},
|
|
"error": null,
|
|
"duration": null,
|
|
"enqueuedAt": "2026-08-22T00:00:00Z",
|
|
"startedAt": null,
|
|
"finishedAt": null,
|
|
"futureField": {"kept": true}
|
|
}))
|
|
.unwrap();
|
|
|
|
assert_eq!(task.task_type, "futureTaskType");
|
|
assert_eq!(task.extra["futureField"], json!({"kept": true}));
|
|
}
|
|
|
|
#[test]
|
|
fn task_index_guard_accepts_global_and_matching_index_details() {
|
|
let task: MeilisearchTask = serde_json::from_value(json!({
|
|
"uid": 42,
|
|
"indexUid": "movies",
|
|
"status": "succeeded",
|
|
"type": "documentAdditionOrUpdate",
|
|
"enqueuedAt": "2026-08-22T00:00:00Z"
|
|
}))
|
|
.unwrap();
|
|
|
|
assert!(ensure_task_index(&task, None).is_ok());
|
|
assert!(ensure_task_index(&task, Some("movies")).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn task_index_guard_rejects_mismatched_and_indexless_details() {
|
|
let mut task: MeilisearchTask = serde_json::from_value(json!({
|
|
"uid": 42,
|
|
"indexUid": "movies",
|
|
"status": "succeeded",
|
|
"type": "documentAdditionOrUpdate",
|
|
"enqueuedAt": "2026-08-22T00:00:00Z"
|
|
}))
|
|
.unwrap();
|
|
|
|
let mismatch = ensure_task_index(&task, Some("books")).unwrap_err();
|
|
assert!(mismatch.contains("belongs to index 'movies'"));
|
|
assert!(mismatch.contains("expected index 'books'"));
|
|
|
|
task.index_uid = None;
|
|
let indexless = ensure_task_index(&task, Some("books")).unwrap_err();
|
|
assert!(indexless.contains("not associated with an index"));
|
|
}
|
|
|
|
#[test]
|
|
fn system_stats_compute_totals_and_top_ten_deterministically() {
|
|
let indexes = (0..12)
|
|
.map(|index| {
|
|
(format!("index-{index:02}"), json!({ "numberOfDocuments": index, "isIndexing": index % 2 == 0 }))
|
|
})
|
|
.collect::<serde_json::Map<_, _>>();
|
|
let (stats, top, refreshed_at) = parse_system_stats(MeilisearchOverviewSection::available(json!({
|
|
"databaseSize": 1000,
|
|
"usedDatabaseSize": 900,
|
|
"lastUpdate": "2026-08-22T00:00:00Z",
|
|
"indexes": indexes
|
|
})));
|
|
|
|
let stats = stats.data.unwrap();
|
|
assert_eq!(stats.index_count, 12);
|
|
assert_eq!(stats.document_count, 66);
|
|
assert_eq!(stats.indexing_count, 6);
|
|
let top = top.data.unwrap();
|
|
assert_eq!(top.len(), 10);
|
|
assert_eq!(top[0].uid, "index-11");
|
|
assert_eq!(top[9].uid, "index-02");
|
|
assert_eq!(refreshed_at.data.flatten().as_deref(), Some("2026-08-22T00:00:00Z"));
|
|
}
|
|
|
|
#[test]
|
|
fn normalizes_canonical_and_legacy_proxy_base_paths() {
|
|
assert_eq!(
|
|
meilisearch_base_url("http://search.example.com:7700/", Some(&json!({ "basePath": "/gateway/meili/" })))
|
|
.unwrap(),
|
|
"http://search.example.com:7700/gateway/meili"
|
|
);
|
|
assert_eq!(
|
|
meilisearch_base_url("https://search.example.com", Some(&json!({ "base_path": "gateway//meili" })))
|
|
.unwrap(),
|
|
"https://search.example.com/gateway/meili"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_the_origin_unchanged_for_empty_proxy_base_paths() {
|
|
for external_config in
|
|
[None, Some(json!({})), Some(json!({ "basePath": "" })), Some(json!({ "basePath": "/" }))]
|
|
{
|
|
assert_eq!(
|
|
meilisearch_base_url("http://search.example.com:7700/", external_config.as_ref()).unwrap(),
|
|
"http://search.example.com:7700"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn config_aware_client_uses_proxy_base_path() {
|
|
let client = MeilisearchClient::new_for_config(
|
|
"https://search.example.com:8443/",
|
|
None,
|
|
true,
|
|
None,
|
|
Some(&json!({ "basePath": "/gateway/meili/" })),
|
|
Duration::from_secs(1),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(client.base_url, "https://search.example.com:8443/gateway/meili");
|
|
}
|
|
#[test]
|
|
fn translates_document_filters_to_meilisearch_syntax() {
|
|
let filter = serde_json::json!({
|
|
"$and": [
|
|
{ "status": "published" },
|
|
{ "rating": { "$gte": 8 } }
|
|
]
|
|
});
|
|
let expression = filter_expression(&filter, Some("id")).unwrap();
|
|
assert!(expression.contains("status = \"published\""));
|
|
assert!(expression.contains("rating >= 8"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_contains_without_assuming_experimental_server_features() {
|
|
let error = filter_expression(&serde_json::json!({ "title": { "$regex": "space" } }), Some("id")).unwrap_err();
|
|
assert!(error.contains("experimental server feature"));
|
|
}
|
|
|
|
#[test]
|
|
fn translates_document_sort_to_meilisearch_syntax() {
|
|
assert_eq!(
|
|
meilisearch_sort_from_request(Some(r#"{"rating":-1,"title":"asc"}"#), Some("id")).unwrap(),
|
|
Some(vec!["rating:desc".to_string(), "title:asc".to_string()])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_native_meilisearch_sort_syntax() {
|
|
assert_eq!(
|
|
meilisearch_sort_from_request(Some("raw.createdAt:desc"), Some("id")).unwrap(),
|
|
Some(vec!["raw.createdAt:desc".to_string()])
|
|
);
|
|
assert_eq!(
|
|
meilisearch_sort_from_request(Some("rating:desc, title"), Some("id")).unwrap(),
|
|
Some(vec!["rating:desc".to_string(), "title:asc".to_string()])
|
|
);
|
|
// The `_id` alias still maps to the real primary key in native syntax.
|
|
assert_eq!(
|
|
meilisearch_sort_from_request(Some("_id:desc"), Some("movie_id")).unwrap(),
|
|
Some(vec!["movie_id:desc".to_string()])
|
|
);
|
|
assert!(meilisearch_sort_from_request(Some("rating:sideways"), Some("id")).is_err());
|
|
assert!(meilisearch_sort_from_request(Some("bad field:asc"), Some("id")).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn passes_through_native_meilisearch_filter_syntax() {
|
|
assert_eq!(
|
|
meilisearch_filter_from_request(Some("status = \"published\" AND rating >= 8"), Some("id")).unwrap(),
|
|
Some(json!("status = \"published\" AND rating >= 8"))
|
|
);
|
|
// JSON filters keep going through the document-store translation.
|
|
assert!(meilisearch_filter_from_request(Some(r#"{"status":"published"}"#), Some("id"))
|
|
.unwrap()
|
|
.and_then(|value| value.as_str().map(str::to_string))
|
|
.unwrap()
|
|
.contains("status = \"published\""));
|
|
}
|
|
|
|
#[test]
|
|
fn passes_through_native_meilisearch_filter_arrays() {
|
|
// The official search API accepts filter as a string or an array; array
|
|
// input must reach the request body unchanged instead of entering the
|
|
// legacy JSON-object translation.
|
|
let filter =
|
|
meilisearch_filter_from_request(Some(r#"[["rating > 3"], "status = \"published\""]"#), Some("id")).unwrap();
|
|
assert_eq!(filter, Some(json!([["rating > 3"], "status = \"published\""])));
|
|
}
|
|
|
|
#[test]
|
|
fn maps_document_identity_alias_to_primary_key() {
|
|
assert_eq!(
|
|
filter_expression(&serde_json::json!({ "_id": "001" }), Some("movie_id")).unwrap(),
|
|
"(movie_id = \"001\")"
|
|
);
|
|
assert_eq!(
|
|
meilisearch_sort_from_request(Some(r#"{"_id":1}"#), Some("movie_id")).unwrap(),
|
|
Some(vec!["movie_id:asc".to_string()])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_rest_request_lines() {
|
|
assert_eq!(parse_rest_request_line("GET /version").unwrap(), (Method::GET, "/version".to_string()));
|
|
assert_eq!(parse_rest_request_line("/health").unwrap(), (Method::GET, "/health".to_string()));
|
|
assert_eq!(
|
|
parse_rest_request_line("GET /indexes/movies?limit=1").unwrap(),
|
|
(Method::GET, "/indexes/movies?limit=1".to_string())
|
|
);
|
|
assert!(parse_rest_request_line("GET /../version").unwrap_err().contains("traversal"));
|
|
assert!(parse_rest_request_line("GET /gateway/%2e%2e/version").unwrap_err().contains("traversal"));
|
|
assert!(parse_rest_request_line("GET /gateway/%2e%2e%2fversion").unwrap_err().contains("traversal"));
|
|
assert!(parse_rest_request_line("CONNECT /health")
|
|
.unwrap_err()
|
|
.contains("Unsupported Meilisearch HTTP method"));
|
|
}
|
|
|
|
#[test]
|
|
fn bounded_rest_body_does_not_end_with_partial_utf8() {
|
|
let mut body = vec![b'a'; super::REST_RESPONSE_MAX_BYTES - 1];
|
|
body.extend_from_slice("界".as_bytes());
|
|
assert_eq!(decode_bounded_rest_body(&body), "a".repeat(super::REST_RESPONSE_MAX_BYTES - 1));
|
|
}
|
|
|
|
#[test]
|
|
fn builds_search_body_with_highlight_and_translated_filter_sort() {
|
|
let body = meilisearch_search_body(
|
|
Some("space"),
|
|
20,
|
|
10,
|
|
Some(r#"{"status":"published"}"#),
|
|
Some(r#"{"rating":-1}"#),
|
|
Some("movie_id"),
|
|
None,
|
|
true,
|
|
Some(0.8),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(body.get("q").and_then(Value::as_str), Some("space"));
|
|
assert_eq!(body.get("offset").and_then(Value::as_u64), Some(20));
|
|
assert_eq!(body.get("limit").and_then(Value::as_u64), Some(10));
|
|
assert_eq!(body.get("filter").and_then(Value::as_str), Some("(status = \"published\")"));
|
|
assert_eq!(
|
|
body.get("sort")
|
|
.and_then(Value::as_array)
|
|
.map(|values| { values.iter().filter_map(Value::as_str).collect::<Vec<_>>() }),
|
|
Some(vec!["rating:desc"])
|
|
);
|
|
assert_eq!(body.get("attributesToHighlight").and_then(Value::as_array).map(Vec::len), Some(1));
|
|
assert_eq!(body.get("highlightPreTag").and_then(Value::as_str), Some("<mark>"));
|
|
assert_eq!(body.get("highlightPostTag").and_then(Value::as_str), Some("</mark>"));
|
|
assert_eq!(body.get("showRankingScore").and_then(Value::as_bool), Some(true));
|
|
assert_eq!(body.get("rankingScoreThreshold").and_then(Value::as_f64), Some(0.8));
|
|
}
|
|
|
|
#[test]
|
|
fn search_body_omits_empty_query_filter_and_sort() {
|
|
let body = meilisearch_search_body(None, 0, 5, None, None, None, None, false, None).unwrap();
|
|
|
|
assert!(body.get("q").is_none());
|
|
assert!(body.get("filter").is_none());
|
|
assert!(body.get("sort").is_none());
|
|
assert!(body.get("hybrid").is_none());
|
|
assert!(body.get("showRankingScore").is_none());
|
|
// Without a query term there is nothing to highlight, so `_formatted`
|
|
// data is not requested.
|
|
assert!(body.get("attributesToHighlight").is_none());
|
|
assert!(body.get("highlightPreTag").is_none());
|
|
assert!(body.get("highlightPostTag").is_none());
|
|
// A zero threshold means "no filtering" and is omitted.
|
|
let body = meilisearch_search_body(None, 0, 5, None, None, None, None, false, Some(0.0)).unwrap();
|
|
assert!(body.get("rankingScoreThreshold").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn search_body_includes_hybrid_options_when_enabled() {
|
|
let body = meilisearch_search_body(
|
|
Some("space"),
|
|
0,
|
|
5,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(&super::MeilisearchHybrid { embedder: "default".to_string(), semantic_ratio: 1.5 }),
|
|
false,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let hybrid = body.get("hybrid").cloned().unwrap();
|
|
assert_eq!(hybrid.get("embedder").and_then(Value::as_str), Some("default"));
|
|
// Out-of-range ratios are clamped into [0, 1].
|
|
assert_eq!(hybrid.get("semanticRatio").and_then(Value::as_f64), Some(1.0));
|
|
|
|
let err = meilisearch_search_body(
|
|
None,
|
|
0,
|
|
5,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(&super::MeilisearchHybrid { embedder: " ".to_string(), semantic_ratio: 0.5 }),
|
|
false,
|
|
None,
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.contains("embedder"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_search_response_and_captures_identity() {
|
|
let value = json!({
|
|
"hits": [
|
|
{ "movie_id": 123, "title": "Alien", "_formatted": { "title": "<mark>Alien</mark>" } }
|
|
],
|
|
"estimatedTotalHits": 42,
|
|
"processingTimeMs": 7
|
|
});
|
|
let result = parse_meilisearch_search_response(value, Some("movie_id"), true, false).unwrap();
|
|
|
|
assert_eq!(result.total_hits, 42);
|
|
assert_eq!(result.processing_time_ms, 7);
|
|
assert_eq!(result.hits.len(), 1);
|
|
assert_eq!(result.hits[0].id_json.as_deref(), Some("123"));
|
|
// JSON crosses the HTTP/Tauri boundary as text so JavaScript never
|
|
// rounds integer identities or document fields before decoding them.
|
|
let document: Value = serde_json::from_str(&result.hits[0].document_json).unwrap();
|
|
assert_eq!(document["movie_id"], json!(123));
|
|
let formatted: Value = serde_json::from_str(result.hits[0].formatted_json.as_deref().unwrap()).unwrap();
|
|
assert_eq!(formatted, json!({ "title": "<mark>Alien</mark>" }));
|
|
assert!(document.get("_formatted").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn search_hit_preserves_real_formatted_and_ranking_score_fields_when_not_requested() {
|
|
// Without highlight/ranking-score requests, same-named keys are user
|
|
// fields and must stay inside the document payload.
|
|
let value = json!({
|
|
"hits": [
|
|
{ "movie_id": 7, "_formatted": { "note": "user data" }, "_rankingScore": 0.99 }
|
|
],
|
|
"estimatedTotalHits": 1,
|
|
"processingTimeMs": 1
|
|
});
|
|
let result = parse_meilisearch_search_response(value.clone(), Some("movie_id"), false, false).unwrap();
|
|
|
|
assert_eq!(result.hits[0].formatted_json, None);
|
|
assert_eq!(result.hits[0].ranking_score_json, None);
|
|
let document: Value = serde_json::from_str(&result.hits[0].document_json).unwrap();
|
|
assert_eq!(document["_formatted"], json!({ "note": "user data" }));
|
|
assert_eq!(document["_rankingScore"], json!(0.99));
|
|
|
|
// With the flags on, Meilisearch-owned metadata is hoisted instead.
|
|
let hoisted = parse_meilisearch_search_response(value, Some("movie_id"), true, true).unwrap();
|
|
let formatted: Value = serde_json::from_str(hoisted.hits[0].formatted_json.as_deref().unwrap()).unwrap();
|
|
let ranking_score: Value =
|
|
serde_json::from_str(hoisted.hits[0].ranking_score_json.as_deref().unwrap()).unwrap();
|
|
let document: Value = serde_json::from_str(&hoisted.hits[0].document_json).unwrap();
|
|
assert_eq!(formatted, json!({ "note": "user data" }));
|
|
assert_eq!(ranking_score, json!(0.99));
|
|
assert!(document.get("_formatted").is_none());
|
|
assert!(document.get("_rankingScore").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn search_identity_never_shadows_a_real_underscore_id_field() {
|
|
// A document may legitimately store its own `_id` field; the dbx identity
|
|
// must live outside the document so the user field survives round-trips.
|
|
let value = json!({
|
|
"hits": [
|
|
{ "movie_id": 7, "_id": "user-owned", "title": "Alien" }
|
|
],
|
|
"estimatedTotalHits": 1,
|
|
"processingTimeMs": 1
|
|
});
|
|
let result = parse_meilisearch_search_response(value, Some("movie_id"), false, false).unwrap();
|
|
|
|
assert_eq!(result.hits[0].id_json.as_deref(), Some("7"));
|
|
let document: Value = serde_json::from_str(&result.hits[0].document_json).unwrap();
|
|
assert_eq!(document["_id"], json!("user-owned"));
|
|
assert_eq!(document["movie_id"], json!(7));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_large_search_responses() {
|
|
let hits: Vec<Value> =
|
|
(0..5000).map(|index| json!({ "movie_id": index, "title": format!("title-{index}") })).collect();
|
|
let result = parse_meilisearch_search_response(
|
|
json!({ "hits": hits, "estimatedTotalHits": 5000 }),
|
|
Some("movie_id"),
|
|
false,
|
|
false,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(result.hits.len(), 5000);
|
|
assert_eq!(result.hits[4999].id_json.as_deref(), Some("4999"));
|
|
let document: Value = serde_json::from_str(&result.hits[4999].document_json).unwrap();
|
|
assert_eq!(document["movie_id"], json!(4999));
|
|
}
|
|
|
|
#[test]
|
|
fn search_transport_keeps_large_integer_identity_and_document_exact() {
|
|
let value: Value =
|
|
serde_json::from_str(r#"{"hits":[{"movie_id":9007199254740993,"title":"Alien"}],"estimatedTotalHits":1}"#)
|
|
.unwrap();
|
|
let result = parse_meilisearch_search_response(value, Some("movie_id"), false, false).unwrap();
|
|
|
|
assert_eq!(result.hits[0].id_json.as_deref(), Some("9007199254740993"));
|
|
assert_eq!(result.hits[0].document_json, r#"{"movie_id":9007199254740993,"title":"Alien"}"#);
|
|
let wire = serde_json::to_value(&result).unwrap();
|
|
assert_eq!(wire["hits"][0]["idJson"], json!("9007199254740993"));
|
|
assert_eq!(wire["hits"][0]["documentJson"], json!(r#"{"movie_id":9007199254740993,"title":"Alien"}"#));
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_documents_requests_every_stored_field() {
|
|
let body =
|
|
super::meilisearch_fetch_body(0, 1000, Some(r#"genre = "sci-fi""#), Some("movie_id:asc"), Some("movie_id"))
|
|
.unwrap();
|
|
|
|
assert_eq!(body.get("fields"), Some(&json!(["*"])));
|
|
assert_eq!(body.get("limit"), Some(&json!(1000)));
|
|
assert_eq!(body.get("offset"), Some(&json!(0)));
|
|
}
|
|
|
|
#[test]
|
|
fn search_response_prefers_estimated_total_and_falls_back_to_total() {
|
|
let estimated = parse_meilisearch_search_response(
|
|
json!({ "hits": [], "estimatedTotalHits": 5, "processingTimeMs": 1 }),
|
|
None,
|
|
false,
|
|
false,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(estimated.total_hits, 5);
|
|
|
|
let fallback = parse_meilisearch_search_response(
|
|
json!({ "hits": [], "totalHits": 9, "processingTimeMs": 2 }),
|
|
None,
|
|
false,
|
|
false,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(fallback.total_hits, 9);
|
|
}
|
|
|
|
#[test]
|
|
fn search_response_requires_a_hit_total() {
|
|
let error = parse_meilisearch_search_response(json!({ "hits": [], "processingTimeMs": 1 }), None, false, false)
|
|
.unwrap_err();
|
|
|
|
assert!(error.contains("hit total"));
|
|
}
|
|
|
|
/// Minimal HTTP/1.1 server that records request lines and serves canned
|
|
/// index-info / search responses.
|
|
async fn spawn_recording_server() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpListener;
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
|
let server_requests = requests.clone();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let Ok((mut socket, _)) = listener.accept().await else { break };
|
|
let requests = server_requests.clone();
|
|
tokio::spawn(async move {
|
|
let mut buf = vec![0u8; 8192];
|
|
let read = socket.read(&mut buf).await.unwrap_or(0);
|
|
let head = String::from_utf8_lossy(&buf[..read]).to_string();
|
|
let request_line = head.lines().next().unwrap_or("").to_string();
|
|
requests.lock().unwrap().push(request_line.clone());
|
|
let body = if request_line.starts_with("GET ") && request_line.contains("/documents/") {
|
|
r#"{"movie_id":"SN-0001","title":"Canonical"}"#
|
|
} else if request_line.starts_with("GET ") {
|
|
r#"{"uid":"movies","primaryKey":"movie_id"}"#
|
|
} else {
|
|
r#"{"hits":[{"movie_id":1}],"estimatedTotalHits":1,"processingTimeMs":1}"#
|
|
};
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
let _ = socket.write_all(response.as_bytes()).await;
|
|
});
|
|
}
|
|
});
|
|
(format!("http://{addr}"), requests)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn repeat_searches_share_one_index_lookup() {
|
|
let (base_url, requests) = spawn_recording_server().await;
|
|
let client = super::MeilisearchClient::new(&base_url, None, false, None, Duration::from_secs(5)).unwrap();
|
|
|
|
super::search_documents(&client, "movies", None, None, None, 20, 0, None, false, None).await.unwrap();
|
|
super::search_documents(&client, "movies", None, None, None, 20, 20, None, false, None).await.unwrap();
|
|
|
|
let requests = requests.lock().unwrap();
|
|
let index_lookups = requests.iter().filter(|line| line.starts_with("GET /indexes/movies ")).count();
|
|
let searches = requests.iter().filter(|line| line.starts_with("POST /indexes/movies/search")).count();
|
|
// The primary-key lookup is cached per index, so steady-state searching
|
|
// is a single `/search` request instead of two round-trips.
|
|
assert_eq!(index_lookups, 1, "requests: {requests:?}");
|
|
assert_eq!(searches, 2, "requests: {requests:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn string_primary_keys_keep_their_string_identity() {
|
|
// serializeDocumentStoreId on the frontend prefixes string ids so the
|
|
// backend does not reinterpret a numeric-looking string as a number.
|
|
let serialized = r#"__dbx_meilisearch_string_id__"123""#;
|
|
assert_eq!(decoded_identity(serialized), json!("123"));
|
|
assert_eq!(identity_path(serialized), "123");
|
|
// A bare numeric id still decodes as a number.
|
|
assert_eq!(decoded_identity("123"), json!(123));
|
|
assert_eq!(identity_path("123"), "123");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_document_fetches_the_canonical_record_by_identity() {
|
|
let (base_url, requests) = spawn_recording_server().await;
|
|
let client = super::MeilisearchClient::new(&base_url, None, false, None, Duration::from_secs(5)).unwrap();
|
|
|
|
// A string id arrives in the serialized form and must keep its string
|
|
// identity in the request path.
|
|
let document_json =
|
|
super::get_document(&client, "movies", r#"__dbx_meilisearch_string_id__"SN-0001""#).await.unwrap();
|
|
|
|
let document: Value = serde_json::from_str(&document_json).unwrap();
|
|
assert_eq!(document["title"], json!("Canonical"));
|
|
let requests = requests.lock().unwrap();
|
|
assert!(
|
|
requests.iter().any(|line| line.starts_with("GET /indexes/movies/documents/SN-0001?fields=*")),
|
|
"requests: {requests:?}"
|
|
);
|
|
}
|
|
}
|