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
18/// The header name that corresponds to the API key.
19const API_KEY_HEADER_NAME: HeaderName = HeaderName::from_static("x-typesense-api-key");
20
21/// Typesense Docker image name.
22const TYPESENSE_IMAGE_NAME: &str = "typesense/typesense";
23
24/// Typesense Docker image tag.
25const TYPESENSE_IMAGE_TAG: &str = "29.0";
26
27/// Environment variable key for specifying an optional
28/// Typesense image tag suffix (appended to [`TYPESENSE_IMAGE_TAG`]).
29const TYPESENSE_TAG_SUFFIX_KEY: &str = "OMRY_TYPESENSE_TAG_SUFFIX";
30
31/// Typesense port inside the container.
32const TYPESENSE_PORT_CONTAINER: u16 = 8108;
33
34/// Typesense API key.
35const TYPESENSE_API_KEY: &str = "flora_test";
36
37/// Typesense data directory path inside the container.
38const TYPESENSE_DATA_DIR_CONTAINER: &str = "/data";
39
40/// Typesense image for integration tests.
41#[derive(Debug)]
42pub struct TypesenseImage {
43    /// Temporary path to store data during integration tests.
44    temp_path: TempPathWithString,
45
46    /// Container volume mounts.
47    mounts: Vec<Mount>,
48
49    /// Command to run when the container starts.
50    cmd: Vec<String>,
51
52    /// Typesense port on the side of the container host (i.e. outside the container).
53    host_port: u16,
54
55    /// Typesense ports inside the container.
56    container_ports: Vec<ContainerPort>,
57
58    /// Typesense API key.
59    api_key: SecretBox<str>,
60
61    /// Typesense image tag.
62    image_tag: String,
63}
64
65impl TypesenseImage {
66    /// Creates a new [`TypesenseImage`] with the given image config.
67    ///
68    /// ## Panics
69    /// Panics if we couldn't create a temporary path for Typesense data.
70    #[must_use]
71    pub fn new(config: ImageConfig) -> Self {
72        // If we can't make the temporary directory, it's pretty much a show-stopper,
73        // so might as well panic. So `expect` is okay here.
74        #[allow(clippy::expect_used)]
75        let temp_path = TempPathWithString::new().expect("Couldn't create a temporary path");
76        let mount = Mount::bind_mount(temp_path.as_str(), TYPESENSE_DATA_DIR_CONTAINER);
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,
106            mounts: vec![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 know we can parse this, so `expect` is okay here.
124        #[allow(clippy::expect_used)]
125        format!("http://{}:{}/", Ipv4Addr::LOCALHOST, self.host_port)
126            .parse::<Url>()
127            .expect("Should parse URL")
128    }
129}
130
131impl Image for TypesenseImage {
132    fn name(&self) -> &str {
133        TYPESENSE_IMAGE_NAME
134    }
135
136    fn tag(&self) -> &str {
137        &self.image_tag
138    }
139
140    fn ready_conditions(&self) -> Vec<WaitFor> {
141        // We know the test URL can be parsed, so `expect` is okay.
142        #[allow(clippy::expect_used)]
143        let health_url = self
144            .url()
145            .join("health")
146            .expect("Should be able to join URL");
147
148        // We know a string can be converted to a HeaderValue, so `expect` is okay here.
149        #[allow(clippy::expect_used)]
150        let header_value: HeaderValue = self
151            .api_key
152            .expose_secret()
153            .try_into()
154            .expect("Should convert API key into header value");
155        vec![WaitFor::Http(Box::new(
156            HttpWaitStrategy::new(health_url)
157                .with_header(API_KEY_HEADER_NAME, header_value)
158                .with_response_matcher(|r| r.status() == StatusCode::OK),
159        ))]
160    }
161
162    fn mounts(&self) -> impl IntoIterator<Item = &Mount> {
163        self.mounts.as_slice().iter()
164    }
165
166    fn cmd(&self) -> impl IntoIterator<Item = impl Into<Cow<'_, str>>> {
167        self.cmd.as_slice()
168    }
169
170    fn expose_ports(&self) -> &[ContainerPort] {
171        self.container_ports.as_slice()
172    }
173}
174
175impl Drop for TypesenseImage {
176    #[instrument]
177    fn drop(&mut self) {
178        if let Err(e) = fs::remove_dir_all::<&Path>(self.temp_path.as_path()) {
179            tracing::error!(
180                "Couldn't remove the Typesense temporary directory at {}: {e}",
181                self.temp_path.as_path().display()
182            );
183        }
184    }
185}
186
187/// Convenience alias for an async container for a Typesense image.
188pub type TypesenseContainer = ContainerAsync<TypesenseImage>;
189
190/// A struct holding [`TypesenseContainer`] and providing convenience
191/// methods for interacting with Typesense in integration tests.
192#[derive(Debug)]
193pub struct TestTypesense {
194    /// The Typesense container.
195    container: TypesenseContainer,
196}
197
198impl TestTypesense {
199    /// Starts a Typesense container for integration tests,
200    /// with the given config for integration tests.
201    ///
202    /// ## Errors
203    /// Returns [`anyhow::Error`] if we can't start the Typesense container.
204    pub async fn new<L: ContainerLogConsumer>(config: ContainerConfig<L>) -> anyhow::Result<Self> {
205        let typesense = TypesenseImage::new(config.image_config).with_random_host_port();
206        let typesense = config.configure_container(typesense);
207        let container = typesense.start().await?;
208
209        Ok(Self { container })
210    }
211
212    /// Gets the URL from the container's host and the host port.
213    async fn make_url(&self) -> anyhow::Result<Url> {
214        let container_host = self.container.get_host().await?;
215
216        let internal_port = self
217            .container
218            .image()
219            .expose_ports()
220            .first()
221            .ok_or_else(|| anyhow::anyhow!("flora container has no exposed ports"))?;
222
223        let container_host_port = self.container.get_host_port_ipv4(*internal_port).await?;
224
225        let url = format!("http://{container_host}:{container_host_port}").parse::<Url>()?;
226        Ok(url)
227    }
228
229    /// Makes a [`SearchClientConfig`] for the given Typesense container.
230    ///
231    /// ## Errors
232    /// Returns [`anyhow::Error`] if we can't get the URL for Typesense.
233    pub async fn make_client_config(&self) -> anyhow::Result<SearchClientConfig> {
234        let url = self.make_url().await?;
235        let api_key = self.container.image().api_key.clone();
236
237        Ok(SearchClientConfig {
238            url: Some(url),
239            api_key,
240        })
241    }
242
243    /// Creates a new [`SearchClient`] configured for this Typesense instance.
244    ///
245    /// ## Errors
246    /// Returns [`anyhow::Result`] if we can't:
247    ///
248    /// - Get the config for the client to connect to Typesense
249    /// - Initialize the search client with the config we got
250    pub async fn new_client(&self) -> anyhow::Result<SearchClient> {
251        let client_config = self.make_client_config().await?;
252        let client = SearchClient::new_with_records_collection(client_config).await?;
253        Ok(client)
254    }
255
256    /// Returns a reference to the underlying Typesense container.
257    #[must_use]
258    pub fn container(&self) -> &TypesenseContainer {
259        &self.container
260    }
261}