omry_integration/
typesense.rs

1use crate::containers::{ContainerConfig, ContainerLogConsumer, ImageConfig};
2use crate::helpers::{self, TempPathWithString};
3use omry_search::{SearchClient, SearchClientConfig};
4use reqwest::header::{HeaderName, HeaderValue};
5use reqwest::{StatusCode, Url};
6use secrecy::{ExposeSecret, SecretBox};
7use std::borrow::Cow;
8use std::env;
9use std::fs;
10use std::net::Ipv4Addr;
11use std::path::Path;
12use testcontainers::core::wait::HttpWaitStrategy;
13use testcontainers::core::{ContainerPort, Mount, WaitFor};
14use testcontainers::runners::AsyncRunner;
15use testcontainers::{ContainerAsync, ContainerRequest, Image, ImageExt};
16use tracing::instrument;
17
18const API_KEY_HEADER_NAME: HeaderName = HeaderName::from_static("x-typesense-api-key");
19const TYPESENSE_IMAGE_NAME: &str = "typesense/typesense";
20const TYPESENSE_IMAGE_TAG: &str = "29.0";
21
22/// Environment variable key for specifying an optional
23/// Typesense image tag suffix (appended to [`TYPESENSE_IMAGE_TAG`]).
24const TYPESENSE_TAG_SUFFIX_KEY: &str = "OMRY_TYPESENSE_TAG_SUFFIX";
25
26const TYPESENSE_PORT_CONTAINER: u16 = 8108;
27
28const TYPESENSE_API_KEY: &str = "flora_test";
29const TYPESENSE_DATA_DIR_CONTAINER: &str = "/data";
30
31/// Typesense image for integration tests.
32#[derive(Debug)]
33pub struct TypesenseImage {
34    temp_path: TempPathWithString,
35    mounts: Vec<Mount>,
36    cmd: Vec<String>,
37    host_port: u16,
38    container_ports: Vec<ContainerPort>,
39    api_key: SecretBox<str>,
40    image_tag: String,
41}
42
43impl TypesenseImage {
44    /// Creates a new [`TypesenseImage`] with the given image config.
45    ///
46    /// ## Panics
47    /// Panics if we couldn't create a temporary path for Typesense data.
48    #[must_use]
49    pub fn new(config: ImageConfig) -> Self {
50        // If we can't make the temporary directory, it's pretty much a show-stopper,
51        // so might as well panic. So `expect` is okay here.
52        #[allow(clippy::expect_used)]
53        let temp_path = TempPathWithString::new().expect("Couldn't create a temporary path");
54        let mount = Mount::bind_mount(temp_path.as_str(), TYPESENSE_DATA_DIR_CONTAINER);
55
56        let api_key = TYPESENSE_API_KEY;
57        let cmd = [
58            "--data-dir",
59            TYPESENSE_DATA_DIR_CONTAINER,
60            "--api-key",
61            api_key,
62            "--enable-cors",
63        ]
64        .iter()
65        .map(ToString::to_string)
66        .collect::<Vec<_>>();
67
68        let container_ports = if config.should_expose_ports {
69            vec![TYPESENSE_PORT_CONTAINER.into()]
70        } else {
71            vec![]
72        };
73
74        let (_, host_port) = helpers::free_port_ipv4();
75
76        let tag_suffix = env::vars()
77            .find(|(k, _v)| k == TYPESENSE_TAG_SUFFIX_KEY)
78            .map(|(_k, v)| v)
79            .unwrap_or_default();
80        let image_tag = format!("{TYPESENSE_IMAGE_TAG}{tag_suffix}");
81
82        Self {
83            temp_path,
84            mounts: vec![mount],
85            cmd,
86            host_port,
87            api_key: SecretBox::from(api_key.to_string()),
88            container_ports,
89            image_tag,
90        }
91    }
92
93    fn with_random_host_port(self) -> ContainerRequest<Self> {
94        let host_port = self.host_port;
95        self.with_mapped_port(host_port, TYPESENSE_PORT_CONTAINER.into())
96    }
97
98    fn url(&self) -> Url {
99        // We know we can parse this, so `expect` is okay here.
100        #[allow(clippy::expect_used)]
101        format!("http://{}:{}/", Ipv4Addr::LOCALHOST, self.host_port)
102            .parse::<Url>()
103            .expect("Should parse URL")
104    }
105}
106
107impl Image for TypesenseImage {
108    fn name(&self) -> &str {
109        TYPESENSE_IMAGE_NAME
110    }
111
112    fn tag(&self) -> &str {
113        &self.image_tag
114    }
115
116    fn ready_conditions(&self) -> Vec<WaitFor> {
117        // We know the test URL can be parsed, so `expect` is okay.
118        #[allow(clippy::expect_used)]
119        let health_url = self
120            .url()
121            .join("health")
122            .expect("Should be able to join URL");
123
124        // We know a string can be converted to a HeaderValue, so `expect` is okay here.
125        #[allow(clippy::expect_used)]
126        let header_value: HeaderValue = self
127            .api_key
128            .expose_secret()
129            .try_into()
130            .expect("Should convert API key into header value");
131        vec![WaitFor::Http(Box::new(
132            HttpWaitStrategy::new(health_url)
133                .with_header(API_KEY_HEADER_NAME, header_value)
134                .with_response_matcher(|r| r.status() == StatusCode::OK),
135        ))]
136    }
137
138    fn mounts(&self) -> impl IntoIterator<Item = &Mount> {
139        self.mounts.as_slice().iter()
140    }
141
142    fn cmd(&self) -> impl IntoIterator<Item = impl Into<Cow<'_, str>>> {
143        self.cmd.as_slice()
144    }
145
146    fn expose_ports(&self) -> &[ContainerPort] {
147        self.container_ports.as_slice()
148    }
149}
150
151impl Drop for TypesenseImage {
152    #[instrument]
153    fn drop(&mut self) {
154        if let Err(e) = fs::remove_dir_all::<&Path>(self.temp_path.as_path()) {
155            tracing::error!(
156                "Couldn't remove the Typesense temporary directory at {}: {e}",
157                self.temp_path.as_path().display()
158            );
159        }
160    }
161}
162
163/// Convenience alias for an async container for a Typesense image.
164pub type TypesenseContainer = ContainerAsync<TypesenseImage>;
165
166/// A struct holding [`TypesenseContainer`] and providing convenience
167/// methods for interacting with Typesense in integration tests.
168#[derive(Debug)]
169pub struct TestTypesense {
170    container: TypesenseContainer,
171}
172
173impl TestTypesense {
174    /// Starts a Typesense container for integration tests,
175    /// with the given config for integration tests.
176    ///
177    /// ## Errors
178    /// Returns [`anyhow::Error`] if we can't start the Typesense container.
179    pub async fn new<L: ContainerLogConsumer>(config: ContainerConfig<L>) -> anyhow::Result<Self> {
180        let typesense = TypesenseImage::new(config.image_config).with_random_host_port();
181        let typesense = config.configure_container(typesense);
182        let container = typesense.start().await?;
183
184        Ok(Self { container })
185    }
186
187    /// Gets the URL from the container's host and the host port.
188    async fn make_url(&self) -> anyhow::Result<Url> {
189        let container_host = self.container.get_host().await?;
190
191        let internal_port = self
192            .container
193            .image()
194            .expose_ports()
195            .first()
196            .ok_or_else(|| anyhow::anyhow!("flora container has no exposed ports"))?;
197
198        let container_host_port = self.container.get_host_port_ipv4(*internal_port).await?;
199
200        let url = format!("http://{container_host}:{container_host_port}").parse::<Url>()?;
201        Ok(url)
202    }
203
204    /// Makes a [`SearchClientConfig`] for the given Typesense container.
205    ///
206    /// ## Errors
207    /// Returns [`anyhow::Error`] if we can't get the URL for Typesense.
208    pub async fn make_client_config(&self) -> anyhow::Result<SearchClientConfig> {
209        let url = self.make_url().await?;
210        let api_key = self.container.image().api_key.clone();
211
212        Ok(SearchClientConfig {
213            url: Some(url),
214            api_key,
215        })
216    }
217
218    /// Creates a new [`SearchClient`] configured for this Typesense instance.
219    ///
220    /// ## Errors
221    /// Returns [`anyhow::Result`] if we can't:
222    ///
223    /// - Get the config for the client to connect to Typesense
224    /// - Initialize the search client with the config we got
225    pub async fn new_client(&self) -> anyhow::Result<SearchClient> {
226        let client_config = self.make_client_config().await?;
227        let client = SearchClient::new_with_records_collection(client_config).await?;
228        Ok(client)
229    }
230
231    /// Returns a reference to the underlying Typesense container.
232    #[must_use]
233    pub fn container(&self) -> &TypesenseContainer {
234        &self.container
235    }
236}