omry-integration 0.22.0

Helpers for Omry client applications that run integration tests against the Omry backend.
Documentation
use crate::containers::{ContainerConfig, ContainerLogConsumer, ImageConfig};
use crate::helpers::{self, ContainerMount, DockerHost, 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;

/// The header name that corresponds to the API key.
const API_KEY_HEADER_NAME: HeaderName = HeaderName::from_static("x-typesense-api-key");

/// Typesense Docker image name.
const TYPESENSE_IMAGE_NAME: &str = "typesense/typesense";

/// Typesense Docker image tag.
const TYPESENSE_IMAGE_TAG: &str = "30.2";

/// Environment variable key for specifying an optional
/// Typesense image tag suffix (appended to [`TYPESENSE_IMAGE_TAG`]).
const TYPESENSE_TAG_SUFFIX_KEY: &str = "OMRY_TYPESENSE_TAG_SUFFIX";

/// Typesense port inside the container.
const TYPESENSE_PORT_CONTAINER: u16 = 8108;

/// Typesense API key.
const TYPESENSE_API_KEY: &str = "flora_test";

/// Typesense data directory path inside the container.
const TYPESENSE_DATA_DIR_CONTAINER: &str = "/data";

/// Typesense image for integration tests.
#[derive(Debug)]
pub struct TypesenseImage {
    /// Temporary path to store data during integration tests.
    temp_path: Option<TempPathWithString>,

    /// Container volume mounts.
    mounts: Vec<Mount>,

    /// Command to run when the container starts.
    cmd: Vec<String>,

    /// Typesense port on the side of the container host (i.e. outside the container).
    host_port: u16,

    /// Typesense ports inside the container.
    container_ports: Vec<ContainerPort>,

    /// Typesense API key.
    api_key: SecretBox<str>,

    /// Typesense image tag.
    image_tag: String,
}

impl TypesenseImage {
    /// Creates a new [`TypesenseImage`] with the given image config.
    ///
    /// ## Panics
    /// Panics if we couldn't create a temporary path for Typesense data.
    #[must_use]
    pub fn new(config: ImageConfig) -> Self {
        // If we can't create a container mount, it's a showstopper, so might as well panic.
        #[allow(clippy::expect_used)]
        let container_mount = ContainerMount::new(TYPESENSE_DATA_DIR_CONTAINER)
            .expect("Should create a container mount");

        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: container_mount.temp_path,
            mounts: vec![container_mount.mount],
            cmd,
            host_port,
            api_key: SecretBox::from(api_key.to_string()),
            container_ports,
            image_tag,
        }
    }

    /// Specifies that the container should request any free port from the host OS.
    fn with_random_host_port(self) -> ContainerRequest<Self> {
        let host_port = self.host_port;
        self.with_mapped_port(host_port, TYPESENSE_PORT_CONTAINER.into())
    }

    /// The Typesense URL for this container.
    fn url(&self) -> Url {
        // We don't handle having a `DOCKER_HOST` that is explicitly set,
        // but not remote; so we expect the host URI to always have a host component.
        #[allow(clippy::expect_used)]
        let docker_host = DockerHost::from_env().map_or_else(
            |_| Ipv4Addr::LOCALHOST.to_string(),
            |host| host.host().expect("Must have a host").to_string(),
        );

        // We know we can parse this, so `expect` is okay here.
        #[allow(clippy::expect_used)]
        format!("http://{}:{}/", docker_host, 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> {
        // We know the test URL can be parsed, so `expect` is okay.
        #[allow(clippy::expect_used)]
        let health_url = self
            .url()
            .join("health")
            .expect("Should be able to join URL");

        // We know a string can be converted to a HeaderValue, so `expect` is okay here.
        #[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 Some(temp_path) = &self.temp_path
            && let Err(e) = fs::remove_dir_all::<&Path>(temp_path.as_path())
        {
            tracing::error!(
                "Couldn't remove the Typesense temporary directory at {}: {e}",
                temp_path.as_path().display()
            );
        }
    }
}

/// Convenience alias for an async container for a Typesense image.
pub type TypesenseContainer = ContainerAsync<TypesenseImage>;

/// A struct holding [`TypesenseContainer`] and providing convenience
/// methods for interacting with Typesense in integration tests.
#[derive(Debug)]
pub struct TestTypesense {
    /// The Typesense container.
    container: TypesenseContainer,
}

impl TestTypesense {
    /// Starts a Typesense container for integration tests,
    /// with the given config for integration tests.
    ///
    /// ## Errors
    /// Returns [`anyhow::Error`] if we can't start the Typesense container.
    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 })
    }

    /// Gets the URL from the container's host and the host port.
    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)
    }

    /// Makes a [`SearchClientConfig`] for the given Typesense container.
    ///
    /// ## Errors
    /// Returns [`anyhow::Error`] if we can't get the URL for Typesense.
    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,
        })
    }

    /// Creates a new [`SearchClient`] configured for this Typesense instance.
    ///
    /// ## Errors
    /// Returns [`anyhow::Result`] if we can't:
    ///
    /// - Get the config for the client to connect to Typesense
    /// - Initialize the search client with the config we got
    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)
    }

    /// Returns a reference to the underlying Typesense container.
    #[must_use]
    pub fn container(&self) -> &TypesenseContainer {
        &self.container
    }
}