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");
19const TYPESENSE_IMAGE_NAME: &str = "typesense/typesense";
20const TYPESENSE_IMAGE_TAG: &str = "29.0";
21
22const 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#[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 #[must_use]
49 pub fn new(config: ImageConfig) -> Self {
50 #[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 #[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 #[allow(clippy::expect_used)]
119 let health_url = self
120 .url()
121 .join("health")
122 .expect("Should be able to join URL");
123
124 #[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
163pub type TypesenseContainer = ContainerAsync<TypesenseImage>;
165
166#[derive(Debug)]
169pub struct TestTypesense {
170 container: TypesenseContainer,
171}
172
173impl TestTypesense {
174 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 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 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 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 #[must_use]
233 pub fn container(&self) -> &TypesenseContainer {
234 &self.container
235 }
236}