neo4rs 0.8.0

Rust driver for Neo4j
Documentation
use lenient_semver::Version;
use neo4rs::{ConfigBuilder, Graph};
use testcontainers::{clients::Cli, Container, RunnableImage};
use testcontainers_modules::neo4j::{Neo4j, Neo4jImage};

use std::{error::Error, io::BufRead as _};

#[allow(dead_code)]
#[derive(Default)]
pub struct Neo4jContainerBuilder {
    enterprise: bool,
    config: ConfigBuilder,
}

#[allow(dead_code)]
impl Neo4jContainerBuilder {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_enterprise_edition(mut self) -> Self {
        self.enterprise = true;
        self
    }

    pub fn with_config(mut self, config: ConfigBuilder) -> Self {
        self.config = config;
        self
    }

    pub fn modify_config(mut self, block: impl FnOnce(ConfigBuilder) -> ConfigBuilder) -> Self {
        self.config = block(self.config);
        self
    }

    pub async fn start(self) -> Result<Neo4jContainer, Box<dyn Error + Send + Sync + 'static>> {
        Neo4jContainer::from_config_and_edition(self.config, self.enterprise).await
    }
}

pub struct Neo4jContainer {
    graph: Graph,
    version: String,
    _container: Option<Container<'static, Neo4jImage>>,
}

impl Neo4jContainer {
    #[allow(dead_code)]
    pub async fn new() -> Self {
        Self::from_config(ConfigBuilder::default()).await
    }

    pub async fn from_config(config: ConfigBuilder) -> Self {
        Self::from_config_and_edition(config, false).await.unwrap()
    }

    pub async fn from_config_and_edition(
        config: ConfigBuilder,
        enterprise_edition: bool,
    ) -> Result<Self, Box<dyn Error + Send + Sync + 'static>> {
        let _ = pretty_env_logger::try_init();

        let connection = Self::create_test_endpoint();
        let server = Self::server_from_env();

        let (uri, _container) = match server {
            TestServer::TestContainer => {
                let (uri, container) = Self::create_testcontainer(&connection, enterprise_edition)?;
                (uri, Some(container))
            }
            TestServer::External(uri) => (uri, None),
        };

        let version = connection.version;
        let graph = Self::connect(config, uri, &connection.auth).await;
        Ok(Self {
            graph,
            version,
            _container,
        })
    }

    pub fn graph(&self) -> Graph {
        self.graph.clone()
    }

    #[allow(dead_code)]
    pub fn version(&self) -> Version<'static> {
        Version::parse(&self.version)
            .unwrap()
            .disassociate_metadata()
            .0
    }

    fn server_from_env() -> TestServer {
        const TEST_URI_VAR: &str = "NEO4J_TEST_URI";

        if let Ok(uri) = std::env::var(TEST_URI_VAR) {
            TestServer::External(uri)
        } else {
            TestServer::TestContainer
        }
    }

    fn create_testcontainer(
        connection: &TestConnection,
        enterprise: bool,
    ) -> Result<(String, Container<'static, Neo4jImage>), Box<dyn Error + Send + Sync + 'static>>
    {
        let image = Neo4j::new()
            .with_user(connection.auth.user.to_owned())
            .with_password(connection.auth.pass.to_owned());

        let docker = Cli::default();
        let docker = Box::leak(Box::new(docker));

        let container = if enterprise {
            const ACCEPTANCE_FILE_NAME: &str = "container-license-acceptance.txt";

            let version = format!("{}-enterprise", connection.version);
            let image_name = format!("neo4j:{}", version);

            let acceptance_file = std::env::current_dir()
                .ok()
                .map(|o| o.join(ACCEPTANCE_FILE_NAME));

            let has_license_acceptance = acceptance_file
                .as_deref()
                .and_then(|o| std::fs::File::open(o).ok())
                .into_iter()
                .flat_map(|o| std::io::BufReader::new(o).lines())
                .any(|o| o.map_or(false, |line| line.trim() == image_name));

            if !has_license_acceptance {
                return Err(format!(
                    concat!(
                        "You need to accept the Neo4j Enterprise Edition license by ",
                        "creating the file `{}` with the following content:\n\n\t{}",
                    ),
                    acceptance_file.map_or_else(
                        || ACCEPTANCE_FILE_NAME.to_owned(),
                        |o| { o.display().to_string() }
                    ),
                    image_name
                )
                .into());
            }
            let image: RunnableImage<Neo4jImage> = image.with_version(version).into();
            let image = image.with_env_var(("NEO4J_ACCEPT_LICENSE_AGREEMENT", "yes"));

            docker.run(image)
        } else {
            docker.run(image.with_version(connection.version.to_owned()))
        };

        let uri = format!("bolt://127.0.0.1:{}", container.image().bolt_port_ipv4());

        Ok((uri, container))
    }

    fn create_test_endpoint() -> TestConnection {
        const USER_VAR: &str = "NEO4J_TEST_USER";
        const PASS_VAR: &str = "NEO4J_TEST_PASS";
        const VERSION_VAR: &str = "NEO4J_VERSION_TAG";

        const DEFAULT_USER: &str = "neo4j";
        const DEFAULT_PASS: &str = "neo";
        const DEFAULT_VERSION_TAG: &str = "5";

        use std::env::var;

        let user = var(USER_VAR).unwrap_or_else(|_| DEFAULT_USER.to_owned());
        let pass = var(PASS_VAR).unwrap_or_else(|_| DEFAULT_PASS.to_owned());
        let auth = TestAuth { user, pass };
        let version = var(VERSION_VAR).unwrap_or_else(|_| DEFAULT_VERSION_TAG.to_owned());

        TestConnection { auth, version }
    }

    async fn connect(config: ConfigBuilder, uri: String, auth: &TestAuth) -> Graph {
        let config = config
            .uri(uri)
            .user(&auth.user)
            .password(&auth.pass)
            .build()
            .unwrap();

        Graph::connect(config).await.unwrap()
    }
}

struct TestAuth {
    user: String,
    pass: String,
}

struct TestConnection {
    version: String,
    auth: TestAuth,
}

enum TestServer {
    TestContainer,
    External(String),
}