Skip to main content

omry_integration/
typesense.rs

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