1use anyhow::{Context, Result, anyhow};
2use reqwest::blocking::Client;
3use serde::Serialize;
4use serde_json::{Value, json};
5
6use crate::config::MeiliConnection;
7use crate::models::SearchResponse;
8
9#[derive(Debug, Clone)]
10pub struct MeiliClient {
11 client: Client,
12 connection: MeiliConnection,
13}
14
15impl MeiliClient {
16 pub fn new(connection: MeiliConnection) -> Result<Self> {
17 Ok(Self {
18 client: Client::builder().build()?,
19 connection,
20 })
21 }
22
23 pub fn health(&self) -> Result<Value> {
24 self.get("health")
25 }
26
27 pub fn create_index(&self, name: &str) -> Result<()> {
28 let response = self
29 .client
30 .post(self.url("indexes")?)
31 .bearer_auth(&self.connection.api_key)
32 .json(&json!({ "uid": name }))
33 .send()?;
34 if response.status().is_success() || response.status().as_u16() == 409 {
35 return Ok(());
36 }
37 Err(anyhow!(
38 "failed to create index {name}: {}",
39 response.text()?
40 ))
41 }
42
43 pub fn delete_index(&self, name: &str) -> Result<()> {
44 let response = self
45 .client
46 .delete(self.url(&format!("indexes/{name}"))?)
47 .bearer_auth(&self.connection.api_key)
48 .send()?;
49 if response.status().is_success() || response.status().as_u16() == 404 {
50 return Ok(());
51 }
52 Err(anyhow!(
53 "failed to delete index {name}: {}",
54 response.text()?
55 ))
56 }
57
58 pub fn apply_settings(&self, index: &str, settings: &Value) -> Result<()> {
59 let task = self
60 .client
61 .patch(self.url(&format!("indexes/{index}/settings"))?)
62 .bearer_auth(&self.connection.api_key)
63 .json(settings)
64 .send()?
65 .json::<Value>()?;
66 self.wait_for_task(task_uid(&task)?)?;
67 Ok(())
68 }
69
70 pub fn replace_documents<T: Serialize>(&self, index: &str, documents: &[T]) -> Result<()> {
71 let task = self
72 .client
73 .post(self.url(&format!("indexes/{index}/documents"))?)
74 .bearer_auth(&self.connection.api_key)
75 .json(documents)
76 .send()?
77 .json::<Value>()?;
78 self.wait_for_task(task_uid(&task)?)?;
79 Ok(())
80 }
81
82 pub fn search<T: serde::de::DeserializeOwned>(
83 &self,
84 index: &str,
85 body: Value,
86 ) -> Result<SearchResponse<T>> {
87 Ok(self
88 .client
89 .post(self.url(&format!("indexes/{index}/search"))?)
90 .bearer_auth(&self.connection.api_key)
91 .json(&body)
92 .send()?
93 .json()?)
94 }
95
96 pub fn stats(&self, index: &str) -> Result<Value> {
97 self.get(&format!("indexes/{index}/stats"))
98 }
99
100 pub fn swap_indexes(&self, swaps: Vec<(String, String)>) -> Result<()> {
101 let payload = swaps
102 .into_iter()
103 .map(|(indexes_a, indexes_b)| json!({ "indexes": [indexes_a, indexes_b] }))
104 .collect::<Vec<_>>();
105 let task = self
106 .client
107 .post(self.url("swap-indexes")?)
108 .bearer_auth(&self.connection.api_key)
109 .json(&payload)
110 .send()?
111 .json::<Value>()?;
112 self.wait_for_task(task_uid(&task)?)?;
113 Ok(())
114 }
115
116 pub fn wait_for_task(&self, uid: u64) -> Result<()> {
117 for _ in 0..50 {
118 let task = self.get(&format!("tasks/{uid}"))?;
119 match task.get("status").and_then(Value::as_str) {
120 Some("succeeded") => return Ok(()),
121 Some("failed") => return Err(anyhow!("meilisearch task {uid} failed: {task}")),
122 _ => std::thread::sleep(std::time::Duration::from_millis(100)),
123 }
124 }
125 Err(anyhow!("timed out waiting for meilisearch task {uid}"))
126 }
127
128 fn get(&self, path: &str) -> Result<Value> {
129 Ok(self
130 .client
131 .get(self.url(path)?)
132 .bearer_auth(&self.connection.api_key)
133 .send()?
134 .json()?)
135 }
136
137 fn url(&self, path: &str) -> Result<reqwest::Url> {
138 self.connection
139 .host
140 .join(path)
141 .with_context(|| format!("join meilisearch path {path}"))
142 }
143}
144
145pub fn symbols_settings() -> Value {
146 json!({
147 "searchableAttributes": [
148 "short_name", "fqn", "owner_class", "namespace", "symbol_tokens", "signature",
149 "doc_summary", "doc_description", "param_docs", "return_doc", "throws_docs",
150 "inline_rule_comments", "comment_keywords", "framework_tags", "package_name", "path"
151 ],
152 "filterableAttributes": [
153 "repo", "framework", "kind", "package_name", "is_vendor", "is_project_code", "is_test", "route_ids", "risk_tags"
154 ],
155 "sortableAttributes": ["is_project_code", "related_tests_count", "references_count", "line_start"],
156 "displayedAttributes": [
157 "id", "stable_key", "kind", "fqn", "signature", "doc_summary", "path", "line_start", "package_name", "related_tests", "missing_test_warning"
158 ]
159 })
160}
161
162pub fn routes_settings() -> Value {
163 json!({
164 "searchableAttributes": ["uri", "route_name", "action", "controller", "controller_method", "middleware"],
165 "filterableAttributes": ["repo", "framework", "method"],
166 })
167}
168
169pub fn tests_settings() -> Value {
170 json!({
171 "searchableAttributes": ["fqn", "covered_symbols", "referenced_symbols", "routes_called", "command"],
172 "filterableAttributes": ["repo", "framework"],
173 })
174}
175
176pub fn packages_settings() -> Value {
177 json!({
178 "searchableAttributes": ["name", "description", "keywords"],
179 "filterableAttributes": ["repo", "type"]
180 })
181}
182
183pub fn schema_settings() -> Value {
184 json!({
185 "searchableAttributes": ["migration", "table", "operation", "path"],
186 "filterableAttributes": ["repo", "operation"]
187 })
188}
189
190pub fn runs_settings() -> Value {
191 json!({
192 "searchableAttributes": ["run_id", "framework", "mode"],
193 "filterableAttributes": ["framework", "mode", "index_prefix"]
194 })
195}
196
197fn task_uid(value: &Value) -> Result<u64> {
198 value
199 .get("taskUid")
200 .or_else(|| value.get("uid"))
201 .and_then(Value::as_u64)
202 .ok_or_else(|| anyhow!("meilisearch response missing task uid: {value}"))
203}