sofie_client/
meilisearch.rs1mod tasks;
2
3use std::collections::HashSet;
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6
7use serde_json::{Value, json};
8use sofie_core::{Error, Result, Uuid, unpoisoned};
9use sofie_db::{BoxFuture, Document, MATCHES_READ, SearchEngine, SearchIndex};
10
11use crate::{Outgoing, Request, Response, call};
12
13const VENDOR: &str = "meilisearch";
14
15const SETTINGS_CHECKS: u32 = 20;
18const SETTINGS_CHECK_PAUSE: Duration = Duration::from_millis(50);
19
20pub struct Meilisearch {
21 server: Arc<Server>,
22}
23
24struct Server {
25 http: Outgoing,
26 api_key: Option<String>,
27 prepared: Mutex<HashSet<String>>,
29 tasks: Mutex<tasks::Pending>,
30 check_tasks_every: Duration,
31}
32
33impl Meilisearch {
34 #[must_use]
35 pub fn new(http: Outgoing, api_key: Option<String>) -> Self {
36 Self::built(http, api_key, tasks::CHECK_EVERY)
37 }
38
39 #[cfg(feature = "testing")]
40 #[must_use]
41 pub fn checking_tasks_every(http: Outgoing, api_key: Option<String>, every: Duration) -> Self {
42 Self::built(http, api_key, every)
43 }
44
45 fn built(http: Outgoing, api_key: Option<String>, every: Duration) -> Self {
46 Self {
47 server: Arc::new(Server {
48 http,
49 api_key,
50 prepared: Mutex::default(),
51 tasks: Mutex::default(),
52 check_tasks_every: every,
53 }),
54 }
55 }
56}
57
58impl Server {
59 fn authorized<'r>(&self, request: Request<'r>) -> Request<'r> {
60 match &self.api_key {
61 Some(key) => request.bearer(key),
62 None => request,
63 }
64 }
65
66 async fn search(
67 &self,
68 index: &SearchIndex,
69 words: &str,
70 owner: Option<Uuid>,
71 ) -> Result<Vec<Uuid>> {
72 let mut body = json!({
73 "q": words,
74 "limit": MATCHES_READ,
75 "attributesToRetrieve": ["id"],
76 "matchingStrategy": "all",
77 });
78 if let Some(owner) = owner {
79 body["filter"] = Value::String(format!("owner = \"{owner}\""));
80 }
81 let path = format!("/indexes/{}/search", index.uid());
82 let response = self
83 .authorized(self.http.post(&path))
84 .json(&body)
85 .repeatable()
86 .send()
87 .await?;
88 if response.status() == 404 && error_code(&response).as_deref() == Some("index_not_found") {
89 return Ok(Vec::new());
90 }
91 let answer: Value = accepted(response)?.json()?;
92 answer
93 .get("hits")
94 .and_then(Value::as_array)
95 .ok_or_else(|| Error::internal_message("meilisearch answered a search without hits"))?
96 .iter()
97 .map(hit_id)
98 .collect()
99 }
100
101 async fn add(self: &Arc<Self>, index: &SearchIndex, documents: &[Document]) -> Result<()> {
102 self.prepare(index).await?;
103 let documents: Vec<Value> = documents
104 .iter()
105 .map(|document| {
106 let mut fields = json!({ "id": document.id, "text": document.text });
107 if index.owned() {
108 fields["owner"] = json!(document.owner);
109 }
110 fields
111 })
112 .collect();
113 let path = format!("/indexes/{}/documents?primaryKey=id", index.uid());
114 let request = self.authorized(self.http.post(&path)).json(&documents);
115 self.enqueued(request).await
116 }
117
118 async fn delete(self: &Arc<Self>, index: &SearchIndex, ids: &[Uuid]) -> Result<()> {
119 let path = format!("/indexes/{}/documents/delete-batch", index.uid());
120 let request = self.authorized(self.http.post(&path)).json(ids);
121 self.enqueued(request).await
122 }
123
124 async fn enqueued(self: &Arc<Self>, request: Request<'_>) -> Result<()> {
125 let answer: Value = accepted(request.send().await?)?.json()?;
126 if let Some(task) = answer.get("taskUid").and_then(Value::as_u64) {
127 self.watch(task);
128 }
129 Ok(())
130 }
131
132 async fn prepare(&self, index: &SearchIndex) -> Result<()> {
135 let uid = index.uid();
136 if self.prepared().contains(&uid) {
137 return Ok(());
138 }
139 let filterable: &[&str] = if index.owned() { &["owner"] } else { &[] };
140 let settings = json!({
141 "searchableAttributes": ["text"],
142 "filterableAttributes": filterable,
143 });
144 let path = format!("/indexes/{uid}/settings");
145 let request = self.authorized(self.http.patch(&path)).json(&settings);
146 let task: Value = accepted(request.send().await?)?.json()?;
147 if let Some(task) = task.get("taskUid").and_then(Value::as_u64) {
148 self.settled(task).await?;
149 }
150 self.prepared().insert(uid);
151 Ok(())
152 }
153
154 async fn settled(&self, task: u64) -> Result<()> {
156 let path = format!("/tasks/{task}");
157 for _ in 0..SETTINGS_CHECKS {
158 let response = self.authorized(self.http.get(&path)).send().await?;
159 if matches!(response.status(), 401 | 403) {
160 return Ok(());
161 }
162 let answer: Value = accepted(response)?.json()?;
163 match answer.get("status").and_then(Value::as_str) {
164 Some("enqueued" | "processing") => tokio::time::sleep(SETTINGS_CHECK_PAUSE).await,
165 Some("failed" | "canceled") => {
166 let code = answer
167 .pointer("/error/code")
168 .and_then(Value::as_str)
169 .unwrap_or("no code");
170 return Err(Error::internal_message(format!(
171 "meilisearch refused the settings of an index ({code})"
172 )));
173 }
174 _ => return Ok(()),
175 }
176 }
177 Ok(())
178 }
179
180 fn prepared(&self) -> std::sync::MutexGuard<'_, HashSet<String>> {
181 unpoisoned::lock(&self.prepared)
182 }
183}
184
185impl SearchEngine for Meilisearch {
186 fn matching<'a>(
187 &'a self,
188 index: &'a SearchIndex,
189 words: &'a str,
190 owner: Option<Uuid>,
191 ) -> BoxFuture<'a, Result<Vec<Uuid>>> {
192 Box::pin(call(
193 VENDOR,
194 "matching",
195 self.server.search(index, words, owner),
196 ))
197 }
198
199 fn put<'a>(
200 &'a self,
201 index: &'a SearchIndex,
202 documents: &'a [Document],
203 ) -> BoxFuture<'a, Result<()>> {
204 Box::pin(call(VENDOR, "put", self.server.add(index, documents)))
205 }
206
207 fn remove<'a>(&'a self, index: &'a SearchIndex, ids: &'a [Uuid]) -> BoxFuture<'a, Result<()>> {
208 Box::pin(call(VENDOR, "remove", self.server.delete(index, ids)))
209 }
210}
211
212fn accepted(response: Response) -> Result<Response> {
214 let status = response.status();
215 if (200..300).contains(&status) {
216 return Ok(response);
217 }
218 let code = error_code(&response).unwrap_or_else(|| "no code".to_owned());
219 let problem = format!("meilisearch answered {status} ({code})");
220 Err(if status >= 500 || status == 429 {
221 Error::Unavailable(problem)
222 } else {
223 Error::internal_message(problem)
224 })
225}
226
227fn error_code(response: &Response) -> Option<String> {
228 let body: Value = serde_json::from_slice(response.body()).ok()?;
229 Some(body.get("code")?.as_str()?.to_owned())
230}
231
232fn hit_id(hit: &Value) -> Result<Uuid> {
233 hit.get("id")
234 .and_then(Value::as_str)
235 .and_then(|id| id.parse().ok())
236 .ok_or_else(|| Error::internal_message("meilisearch answered a hit whose id is no id"))
237}