1use 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 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 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 #[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;