Skip to main content

rustio_core/search/
client.rs

1//! A minimal Meilisearch REST client. We don't pull in
2//! `meilisearch-sdk` because we use a small subset — index CRUD, add
3//! documents, search — and the sdk adds several hundred KB of
4//! dependencies for features we don't need.
5
6use std::time::Duration;
7
8use reqwest::Client;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::error::{Error, Result};
13
14#[derive(Clone)]
15pub struct MeiliClient {
16    http: Client,
17    base_url: String,
18    api_key: Option<String>,
19}
20
21impl MeiliClient {
22    pub fn new(base_url: impl Into<String>, api_key: Option<String>) -> Result<Self> {
23        let http = Client::builder()
24            .timeout(Duration::from_secs(10))
25            .connect_timeout(Duration::from_secs(2))
26            .pool_idle_timeout(Duration::from_secs(30))
27            .pool_max_idle_per_host(8)
28            .build()
29            .map_err(|e| Error::Internal(format!("reqwest build: {e}")))?;
30        Ok(Self {
31            http,
32            base_url: base_url.into().trim_end_matches('/').to_string(),
33            api_key,
34        })
35    }
36
37    pub async fn health(&self) -> Result<()> {
38        let url = format!("{}/health", self.base_url);
39        let resp = self.http.get(&url).send().await.map_err(req_err)?;
40        if !resp.status().is_success() {
41            return Err(Error::Internal(format!(
42                "meili health returned {}",
43                resp.status()
44            )));
45        }
46        Ok(())
47    }
48
49    /// Push documents into an index. Meili creates the index on the
50    /// first call — no explicit `create_index` required.
51    pub async fn add_documents(
52        &self,
53        index: &str,
54        documents: &[Value],
55        primary_key: &str,
56    ) -> Result<()> {
57        let url = format!(
58            "{}/indexes/{}/documents?primaryKey={}",
59            self.base_url, index, primary_key
60        );
61        let mut req = self.http.post(&url).json(documents);
62        if let Some(key) = &self.api_key {
63            req = req.bearer_auth(key);
64        }
65        let resp = req.send().await.map_err(req_err)?;
66        if !resp.status().is_success() {
67            let status = resp.status();
68            let body = resp.text().await.unwrap_or_default();
69            return Err(Error::Internal(format!(
70                "meili add_documents {status}: {body}"
71            )));
72        }
73        Ok(())
74    }
75
76    pub async fn delete_document(&self, index: &str, id: &str) -> Result<()> {
77        let url = format!("{}/indexes/{}/documents/{}", self.base_url, index, id);
78        let mut req = self.http.delete(&url);
79        if let Some(key) = &self.api_key {
80            req = req.bearer_auth(key);
81        }
82        let resp = req.send().await.map_err(req_err)?;
83        if !resp.status().is_success() && resp.status() != 404 {
84            return Err(Error::Internal(format!(
85                "meili delete_document: {}",
86                resp.status()
87            )));
88        }
89        Ok(())
90    }
91
92    pub async fn search(
93        &self,
94        index: &str,
95        query: &str,
96        options: &SearchOptions,
97    ) -> Result<SearchResults> {
98        let url = format!("{}/indexes/{}/search", self.base_url, index);
99        let body = SearchRequest {
100            q: query,
101            limit: options.limit,
102            offset: options.offset,
103            filter: options.filter.as_deref(),
104            sort: options.sort.as_deref(),
105            attributes_to_highlight: options.highlight.as_deref(),
106            facets: options.facets.as_deref(),
107            highlight_pre_tag: options.highlight_pre_tag.as_deref(),
108            highlight_post_tag: options.highlight_post_tag.as_deref(),
109        };
110        let mut req = self.http.post(&url).json(&body);
111        if let Some(key) = &self.api_key {
112            req = req.bearer_auth(key);
113        }
114        let resp = req.send().await.map_err(req_err)?;
115        if !resp.status().is_success() {
116            return Err(Error::Internal(format!("meili search: {}", resp.status())));
117        }
118        let parsed: SearchResults = resp.json().await.map_err(req_err)?;
119        Ok(parsed)
120    }
121
122    pub async fn configure_index(
123        &self,
124        index: &str,
125        searchable: &[&str],
126        filterable: &[&str],
127        sortable: &[&str],
128    ) -> Result<()> {
129        let url = format!("{}/indexes/{}/settings", self.base_url, index);
130        let body = serde_json::json!({
131            "searchableAttributes": searchable,
132            "filterableAttributes": filterable,
133            "sortableAttributes": sortable,
134        });
135        let mut req = self.http.patch(&url).json(&body);
136        if let Some(key) = &self.api_key {
137            req = req.bearer_auth(key);
138        }
139        let resp = req.send().await.map_err(req_err)?;
140        if !resp.status().is_success() {
141            return Err(Error::Internal(format!(
142                "meili configure_index: {}",
143                resp.status()
144            )));
145        }
146        Ok(())
147    }
148}
149
150fn req_err(e: reqwest::Error) -> Error {
151    Error::Internal(format!("reqwest: {e}"))
152}
153
154#[derive(Debug, Clone, Default)]
155pub struct SearchOptions {
156    pub limit: Option<u64>,
157    pub offset: Option<u64>,
158    pub filter: Option<String>,
159    pub sort: Option<Vec<String>>,
160    pub highlight: Option<Vec<String>>,
161    pub facets: Option<Vec<String>>,
162    /// Markers wrapped around every matched substring in the `_formatted`
163    /// payload. Keep them distinctive so the renderer can swap them out
164    /// for `<em>` / `</em>` (or anything else) safely.
165    pub highlight_pre_tag: Option<String>,
166    pub highlight_post_tag: Option<String>,
167}
168
169#[derive(Serialize)]
170struct SearchRequest<'a> {
171    q: &'a str,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    limit: Option<u64>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    offset: Option<u64>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    filter: Option<&'a str>,
178    #[serde(skip_serializing_if = "Option::is_none", rename = "sort")]
179    sort: Option<&'a [String]>,
180    #[serde(skip_serializing_if = "Option::is_none", rename = "attributesToHighlight")]
181    attributes_to_highlight: Option<&'a [String]>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    facets: Option<&'a [String]>,
184    #[serde(skip_serializing_if = "Option::is_none", rename = "highlightPreTag")]
185    highlight_pre_tag: Option<&'a str>,
186    #[serde(skip_serializing_if = "Option::is_none", rename = "highlightPostTag")]
187    highlight_post_tag: Option<&'a str>,
188}
189
190#[derive(Debug, Deserialize, Serialize)]
191pub struct SearchResults {
192    #[serde(default)]
193    pub hits: Vec<SearchHit>,
194    #[serde(default, rename = "estimatedTotalHits")]
195    pub estimated_total: u64,
196    #[serde(default, rename = "processingTimeMs")]
197    pub processing_time_ms: u64,
198    /// For every requested facet, `{value → count}`. Empty when the
199    /// request did not ask for facets.
200    #[serde(
201        default,
202        rename = "facetDistribution",
203        skip_serializing_if = "std::collections::BTreeMap::is_empty"
204    )]
205    pub facet_distribution:
206        std::collections::BTreeMap<String, std::collections::BTreeMap<String, u64>>,
207}
208
209pub type SearchHit = Value;