omry_integration/
typesense.rs1use 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
19const API_KEY_HEADER_NAME: HeaderName = HeaderName::from_static("x-typesense-api-key");
21
22const TYPESENSE_IMAGE_NAME: &str = "typesense/typesense";
24
25const TYPESENSE_IMAGE_TAG: &str = "30.2";
27
28const TYPESENSE_TAG_SUFFIX_KEY: &str = "OMRY_TYPESENSE_TAG_SUFFIX";
31
32const TYPESENSE_PORT_CONTAINER: u16 = 8108;
34
35const TYPESENSE_API_KEY: &str = "flora_test";
37
38const TYPESENSE_DATA_DIR_CONTAINER: &str = "/data";
40
41#[derive(Debug)]
43pub struct TypesenseImage {
44 temp_path: Option<TempPathWithString>,
46
47 mounts: Vec<Mount>,
49
50 cmd: Vec<String>,
52
53 host_port: u16,
55
56 container_ports: Vec<ContainerPort>,
58
59 api_key: SecretBox<str>,
61
62 image_tag: String,
64}
65
66impl TypesenseImage {
67 #[must_use]
72 pub fn new(config: ImageConfig) -> Self {
73 #[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 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 fn url(&self) -> Url {
123 #[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 #[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 #[allow(clippy::expect_used)]
151 let health_url = self
152 .url()
153 .join("health")
154 .expect("Should be able to join URL");
155
156 #[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
197pub type TypesenseContainer = ContainerAsync<TypesenseImage>;
199
200#[derive(Debug)]
203pub struct TestTypesense {
204 container: TypesenseContainer,
206}
207
208impl TestTypesense {
209 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 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 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 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 #[must_use]
269 pub fn container(&self) -> &TypesenseContainer {
270 &self.container
271 }
272}