use crate::containers::{ContainerConfig, ContainerLogConsumer, ImageConfig};
use crate::helpers::{self, TempPathWithString};
use omry_search::{SearchClient, SearchClientConfig};
use reqwest::header::{HeaderName, HeaderValue};
use reqwest::{StatusCode, Url};
use secrecy::{ExposeSecret, SecretBox};
use std::borrow::Cow;
use std::env;
use std::fs;
use std::net::Ipv4Addr;
use std::path::Path;
use testcontainers::core::wait::HttpWaitStrategy;
use testcontainers::core::{ContainerPort, Mount, WaitFor};
use testcontainers::runners::AsyncRunner;
use testcontainers::{ContainerAsync, ContainerRequest, Image, ImageExt};
use tracing::instrument;
const API_KEY_HEADER_NAME: HeaderName = HeaderName::from_static("x-typesense-api-key");
const TYPESENSE_IMAGE_NAME: &str = "typesense/typesense";
const TYPESENSE_IMAGE_TAG: &str = "29.0";
const TYPESENSE_TAG_SUFFIX_KEY: &str = "OMRY_TYPESENSE_TAG_SUFFIX";
const TYPESENSE_PORT_CONTAINER: u16 = 8108;
const TYPESENSE_API_KEY: &str = "flora_test";
const TYPESENSE_DATA_DIR_CONTAINER: &str = "/data";
#[derive(Debug)]
pub struct TypesenseImage {
temp_path: TempPathWithString,
mounts: Vec<Mount>,
cmd: Vec<String>,
host_port: u16,
container_ports: Vec<ContainerPort>,
api_key: SecretBox<str>,
image_tag: String,
}
impl TypesenseImage {
#[must_use]
pub fn new(config: ImageConfig) -> Self {
#[allow(clippy::expect_used)]
let temp_path = TempPathWithString::new().expect("Couldn't create a temporary path");
let mount = Mount::bind_mount(temp_path.as_str(), TYPESENSE_DATA_DIR_CONTAINER);
let api_key = TYPESENSE_API_KEY;
let cmd = [
"--data-dir",
TYPESENSE_DATA_DIR_CONTAINER,
"--api-key",
api_key,
"--enable-cors",
]
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
let container_ports = if config.should_expose_ports {
vec![TYPESENSE_PORT_CONTAINER.into()]
} else {
vec![]
};
let (_, host_port) = helpers::free_port_ipv4();
let tag_suffix = env::vars()
.find(|(k, _v)| k == TYPESENSE_TAG_SUFFIX_KEY)
.map(|(_k, v)| v)
.unwrap_or_default();
let image_tag = format!("{TYPESENSE_IMAGE_TAG}{tag_suffix}");
Self {
temp_path,
mounts: vec![mount],
cmd,
host_port,
api_key: SecretBox::from(api_key.to_string()),
container_ports,
image_tag,
}
}
fn with_random_host_port(self) -> ContainerRequest<Self> {
let host_port = self.host_port;
self.with_mapped_port(host_port, TYPESENSE_PORT_CONTAINER.into())
}
fn url(&self) -> Url {
#[allow(clippy::expect_used)]
format!("http://{}:{}/", Ipv4Addr::LOCALHOST, self.host_port)
.parse::<Url>()
.expect("Should parse URL")
}
}
impl Image for TypesenseImage {
fn name(&self) -> &str {
TYPESENSE_IMAGE_NAME
}
fn tag(&self) -> &str {
&self.image_tag
}
fn ready_conditions(&self) -> Vec<WaitFor> {
#[allow(clippy::expect_used)]
let health_url = self
.url()
.join("health")
.expect("Should be able to join URL");
#[allow(clippy::expect_used)]
let header_value: HeaderValue = self
.api_key
.expose_secret()
.try_into()
.expect("Should convert API key into header value");
vec![WaitFor::Http(Box::new(
HttpWaitStrategy::new(health_url)
.with_header(API_KEY_HEADER_NAME, header_value)
.with_response_matcher(|r| r.status() == StatusCode::OK),
))]
}
fn mounts(&self) -> impl IntoIterator<Item = &Mount> {
self.mounts.as_slice().iter()
}
fn cmd(&self) -> impl IntoIterator<Item = impl Into<Cow<'_, str>>> {
self.cmd.as_slice()
}
fn expose_ports(&self) -> &[ContainerPort] {
self.container_ports.as_slice()
}
}
impl Drop for TypesenseImage {
#[instrument]
fn drop(&mut self) {
if let Err(e) = fs::remove_dir_all::<&Path>(self.temp_path.as_path()) {
tracing::error!(
"Couldn't remove the Typesense temporary directory at {}: {e}",
self.temp_path.as_path().display()
);
}
}
}
pub type TypesenseContainer = ContainerAsync<TypesenseImage>;
#[derive(Debug)]
pub struct TestTypesense {
container: TypesenseContainer,
}
impl TestTypesense {
pub async fn new<L: ContainerLogConsumer>(config: ContainerConfig<L>) -> anyhow::Result<Self> {
let typesense = TypesenseImage::new(config.image_config).with_random_host_port();
let typesense = config.configure_container(typesense);
let container = typesense.start().await?;
Ok(Self { container })
}
async fn make_url(&self) -> anyhow::Result<Url> {
let container_host = self.container.get_host().await?;
let internal_port = self
.container
.image()
.expose_ports()
.first()
.ok_or_else(|| anyhow::anyhow!("flora container has no exposed ports"))?;
let container_host_port = self.container.get_host_port_ipv4(*internal_port).await?;
let url = format!("http://{container_host}:{container_host_port}").parse::<Url>()?;
Ok(url)
}
pub async fn make_client_config(&self) -> anyhow::Result<SearchClientConfig> {
let url = self.make_url().await?;
let api_key = self.container.image().api_key.clone();
Ok(SearchClientConfig {
url: Some(url),
api_key,
})
}
pub async fn new_client(&self) -> anyhow::Result<SearchClient> {
let client_config = self.make_client_config().await?;
let client = SearchClient::new_with_records_collection(client_config).await?;
Ok(client)
}
#[must_use]
pub fn container(&self) -> &TypesenseContainer {
&self.container
}
}