omry_integration/
typesense.rs1use 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");
20
21const TYPESENSE_IMAGE_NAME: &str = "typesense/typesense";
23
24const TYPESENSE_IMAGE_TAG: &str = "29.0";
26
27const TYPESENSE_TAG_SUFFIX_KEY: &str = "OMRY_TYPESENSE_TAG_SUFFIX";
30
31const TYPESENSE_PORT_CONTAINER: u16 = 8108;
33
34const TYPESENSE_API_KEY: &str = "flora_test";
36
37const TYPESENSE_DATA_DIR_CONTAINER: &str = "/data";
39
40#[derive(Debug)]
42pub struct TypesenseImage {
43 temp_path: TempPathWithString,
45
46 mounts: Vec<Mount>,
48
49 cmd: Vec<String>,
51
52 host_port: u16,
54
55 container_ports: Vec<ContainerPort>,
57
58 api_key: SecretBox<str>,
60
61 image_tag: String,
63}
64
65impl TypesenseImage {
66 #[must_use]
71 pub fn new(config: ImageConfig) -> Self {
72 #[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 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)]
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 #[allow(clippy::expect_used)]
143 let health_url = self
144 .url()
145 .join("health")
146 .expect("Should be able to join URL");
147
148 #[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
187pub type TypesenseContainer = ContainerAsync<TypesenseImage>;
189
190#[derive(Debug)]
193pub struct TestTypesense {
194 container: TypesenseContainer,
196}
197
198impl TestTypesense {
199 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 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 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 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 #[must_use]
258 pub fn container(&self) -> &TypesenseContainer {
259 &self.container
260 }
261}