testcontainers_modules/meilisearch/
mod.rs

1use std::{borrow::Cow, collections::HashMap};
2
3use parse_display::{Display, FromStr};
4use testcontainers::{
5    core::{wait::HttpWaitStrategy, ContainerPort, WaitFor},
6    Image,
7};
8
9const NAME: &str = "getmeili/meilisearch";
10const TAG: &str = "v1.8.3";
11/// Port that the [`Meilisearch`] container has internally
12/// Can be rebound externally via [`testcontainers::core::ImageExt::with_mapped_port`]
13///
14/// [`Meilisearch`]: https://www.meilisearch.com
15pub const MEILISEARCH_PORT: ContainerPort = ContainerPort::Tcp(7700);
16
17/// Module to work with [`Meilisearch`] inside of tests.
18///
19/// Starts an instance of Meilisearch.
20/// This module is based on the official [`Meilisearch docker image`] documented in the [`Meilisearch docker docs`].
21///
22/// # Example
23/// ```
24/// use testcontainers_modules::{meilisearch, testcontainers::runners::SyncRunner};
25///
26/// let meilisearch_instance = meilisearch::Meilisearch::default().start().unwrap();
27///
28/// let dashboard = format!(
29///     "http://{}:{}",
30///     meilisearch_instance.get_host().unwrap(),
31///     meilisearch_instance.get_host_port_ipv4(7700).unwrap()
32/// );
33/// ```
34///
35/// [`Meilisearch`]: https://www.meilisearch.com/
36/// [`Meilisearch docker docs`]: https://www.meilisearch.com/docs/guides/misc/docker
37/// [`Meilisearch docker image`]: https://hub.docker.com/_/getmeili/meilisearch
38#[derive(Debug, Clone)]
39pub struct Meilisearch {
40    env_vars: HashMap<String, String>,
41}
42
43/// Sets the environment of the [`Meilisearch`] instance.
44#[derive(Display, FromStr, Default, Debug, Clone, Copy, Eq, PartialEq)]
45#[display(style = "lowercase")]
46pub enum Environment {
47    /// This environment is meant for production deployments:
48    /// - Requires authentication via [Meilisearch::with_master_key]
49    /// - Disables the dashboard avalible at [MEILISEARCH_PORT]
50    Production,
51    /// This environment is meant for development:
52    /// - Enables access without authentication
53    /// - Enables the dashboard avalible at [MEILISEARCH_PORT]
54    #[default]
55    Development,
56}
57
58/// Sets the log level of the [`Meilisearch`] instance.
59#[derive(Display, FromStr, Default, Debug, Clone, Copy, Eq, PartialEq)]
60#[display(style = "UPPERCASE")]
61pub enum LogLevel {
62    /// Log everithing with `Error` severity
63    Error,
64    /// Log everithing with `Warn` severity or higher
65    Warn,
66    /// Log everithing with `Info` severity or higher
67    /// Is the default
68    #[default]
69    Info,
70    /// Log everithing with `Debug` severity or higher
71    Debug,
72    /// Log everithing
73    Trace,
74    /// Don't log anything
75    Off,
76}
77
78impl Meilisearch {
79    /// Sets the `MASTER_KEY` for the [`Meilisearch`] instance.
80    /// Default `MASTER_KEY` is `None` if not overridden by this function
81    ///
82    /// See the [official docs for this option](https://www.meilisearch.com/docs/learn/configuration/instance_options#master-key)
83    pub fn with_master_key(mut self, master_key: &str) -> Self {
84        self.env_vars
85            .insert("MEILI_MASTER_KEY".to_owned(), master_key.to_owned());
86        self
87    }
88
89    /// Configures analytics for the [`Meilisearch`] instance.
90    /// Default is `false` if not overridden by this function
91    /// This default differs from the dockerfile as we expect tests not to be good analytics.
92    ///
93    /// See the [official docs for this option](https://www.meilisearch.com/docs/learn/configuration/instance_options#log-level)
94    pub fn with_analytics(mut self, enabled: bool) -> Self {
95        if enabled {
96            self.env_vars.remove("MEILI_NO_ANALYTICS");
97        } else {
98            self.env_vars
99                .insert("MEILI_NO_ANALYTICS".to_owned(), "true".to_owned());
100        }
101        self
102    }
103
104    /// Sets the environment of the [`Meilisearch`] instance.
105    /// Default is [Environment::Development] if not overridden by this function.
106    /// Setting it to [Environment::Production] requires authentication via [Meilisearch::with_master_key]
107    ///
108    /// See the [official docs for this option](https://www.meilisearch.com/docs/learn/configuration/instance_options#environment)
109    pub fn with_environment(mut self, environment: Environment) -> Self {
110        self.env_vars
111            .insert("MEILI_ENV".to_owned(), environment.to_string());
112        self
113    }
114
115    /// Sets the log level of the [`Meilisearch`] instance.
116    /// Default is [LogLevel::Info] if not overridden by this function.
117    ///
118    /// See the [official docs for this option](https://www.meilisearch.com/docs/learn/configuration/instance_options#disable-analytics)
119    pub fn with_log_level(mut self, level: LogLevel) -> Self {
120        self.env_vars
121            .insert("MEILI_LOG_LEVEL".to_owned(), level.to_string());
122        self
123    }
124}
125
126impl Default for Meilisearch {
127    /**
128     * Starts an instance
129     * - in `development` mode (see [Meilisearch::with_environment] to change this)
130     * - without `MASTER_KEY` being set (see [Meilisearch::with_master_key] to change this)
131     * - with Analytics disabled (see [Meilisearch::with_analytics] to change this)
132     */
133    fn default() -> Self {
134        let mut env_vars = HashMap::new();
135        env_vars.insert("MEILI_NO_ANALYTICS".to_owned(), "true".to_owned());
136        Self { env_vars }
137    }
138}
139
140impl Image for Meilisearch {
141    fn name(&self) -> &str {
142        NAME
143    }
144
145    fn tag(&self) -> &str {
146        TAG
147    }
148
149    fn ready_conditions(&self) -> Vec<WaitFor> {
150        // the container does allow for turning off logging entirely and does not have a healthcheck
151        // => using the `/health` endpoint is the best strategy
152        vec![WaitFor::http(
153            HttpWaitStrategy::new("/health")
154                .with_expected_status_code(200_u16)
155                .with_body(r#"{ "status": "available" }"#.as_bytes()),
156        )]
157    }
158
159    fn env_vars(
160        &self,
161    ) -> impl IntoIterator<Item = (impl Into<Cow<'_, str>>, impl Into<Cow<'_, str>>)> {
162        &self.env_vars
163    }
164
165    fn expose_ports(&self) -> &[ContainerPort] {
166        &[MEILISEARCH_PORT]
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use meilisearch_sdk::{client::Client, indexes::Index};
173    use serde::{Deserialize, Serialize};
174    use testcontainers::{runners::AsyncRunner, ImageExt};
175
176    use super::*;
177    #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
178    struct Movie {
179        id: i64,
180        title: String,
181    }
182
183    impl From<(i64, &str)> for Movie {
184        fn from((id, title): (i64, &str)) -> Self {
185            Self {
186                id,
187                title: title.to_owned(),
188            }
189        }
190    }
191
192    impl Movie {
193        fn examples() -> Vec<Self> {
194            vec![
195                Movie::from((1, "The Shawshank Redemption")),
196                Movie::from((2, "The Godfather")),
197                Movie::from((3, "The Dark Knight")),
198                Movie::from((4, "Pulp Fiction")),
199                Movie::from((5, "The Lord of the Rings: The Return of the King")),
200                Movie::from((6, "Forrest Gump")),
201                Movie::from((7, "Inception")),
202                Movie::from((8, "Fight Club")),
203                Movie::from((9, "The Matrix")),
204                Movie::from((10, "Goodfellas")),
205            ]
206        }
207        async fn get_index_with_loaded_examples(
208            client: &Client,
209        ) -> Result<Index, Box<dyn std::error::Error + 'static>> {
210            let task = client
211                .create_index("movies", None)
212                .await?
213                .wait_for_completion(client, None, None)
214                .await?;
215            let movies = task.try_make_index(client).unwrap();
216            assert_eq!(movies.as_ref(), "movies");
217            movies
218                .add_documents(&Movie::examples(), Some("id"))
219                .await?
220                .wait_for_completion(client, None, None)
221                .await?;
222            Ok(movies)
223        }
224    }
225
226    #[tokio::test]
227    async fn meilisearch_noauth() -> Result<(), Box<dyn std::error::Error + 'static>> {
228        let meilisearch_image = Meilisearch::default();
229        let node = meilisearch_image.start().await?;
230
231        let connection_string = &format!(
232            "http://{}:{}",
233            node.get_host().await?,
234            node.get_host_port_ipv4(7700).await?,
235        );
236        let auth: Option<String> = None; // not currently possible to type-infer String or that it is not nessesary
237        let client = Client::new(connection_string, auth).unwrap();
238
239        // healthcheck
240        let res = client.health().await.unwrap();
241        assert_eq!(res.status, "available");
242
243        // insert documents and search for them
244        let movies = Movie::get_index_with_loaded_examples(&client).await?;
245        let res = movies
246            .search()
247            .with_query("Dark Knig")
248            .with_limit(5)
249            .execute::<Movie>()
250            .await?;
251        let results = res
252            .hits
253            .into_iter()
254            .map(|r| r.result)
255            .collect::<Vec<Movie>>();
256        assert_eq!(
257            results,
258            vec![Movie {
259                id: 3,
260                title: String::from("The Dark Knight")
261            }]
262        );
263        assert_eq!(res.estimated_total_hits, Some(1));
264
265        Ok(())
266    }
267
268    #[tokio::test]
269    async fn meilisearch_custom_version() -> Result<(), Box<dyn std::error::Error + 'static>> {
270        let master_key = "secret master key".to_owned();
271        let meilisearch_image = Meilisearch::default()
272            .with_master_key(&master_key)
273            .with_tag("v1.0");
274        let node = meilisearch_image.start().await?;
275
276        let connection_string = &format!(
277            "http://{}:{}",
278            node.get_host().await?,
279            node.get_host_port_ipv4(7700).await?,
280        );
281        let client = Client::new(connection_string, Some(master_key)).unwrap();
282
283        // insert documents and search for it
284        let movies = Movie::get_index_with_loaded_examples(&client).await?;
285        let res = movies
286            .search()
287            .with_query("Dark Knig")
288            .execute::<Movie>()
289            .await?;
290        let result_ids = res
291            .hits
292            .into_iter()
293            .map(|r| r.result.id)
294            .collect::<Vec<i64>>();
295        assert_eq!(result_ids, vec![3]);
296        Ok(())
297    }
298
299    #[tokio::test]
300    async fn meilisearch_without_logging_in_production_environment(
301    ) -> Result<(), Box<dyn std::error::Error + 'static>> {
302        let master_key = "secret master key".to_owned();
303        let meilisearch_image = Meilisearch::default()
304            .with_environment(Environment::Production)
305            .with_log_level(LogLevel::Off)
306            .with_master_key(&master_key);
307        let node = meilisearch_image.start().await?;
308
309        let connection_string = &format!(
310            "http://{}:{}",
311            node.get_host().await?,
312            node.get_host_port_ipv4(7700).await?,
313        );
314        let client = Client::new(connection_string, Some(master_key)).unwrap();
315
316        // insert documents and search for it
317        let movies = Movie::get_index_with_loaded_examples(&client).await?;
318        let res = movies
319            .search()
320            .with_query("Dark Knig")
321            .execute::<Movie>()
322            .await?;
323        let result_ids = res
324            .hits
325            .into_iter()
326            .map(|r| r.result.id)
327            .collect::<Vec<i64>>();
328        assert_eq!(result_ids, vec![3]);
329        Ok(())
330    }
331}