testcontainers_modules/neo4j/
mod.rs

1use std::{
2    borrow::Cow,
3    collections::{BTreeSet, HashMap},
4    sync::RwLock,
5};
6
7use testcontainers::{
8    core::{ContainerState, IntoContainerPort, WaitFor},
9    ContainerRequest, Image, TestcontainersError,
10};
11
12/// Available Neo4j plugins.
13/// See [Neo4j operations manual](https://neo4j.com/docs/operations-manual/current/docker/operations/#docker-neo4j-plugins) for more information.
14#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
15#[non_exhaustive]
16pub enum Neo4jLabsPlugin {
17    /// APOC (Awesome Procedures on Cypher) **Extended** contains additional procedures and functions, which is available when you self-host the database and add the apoc-extended jar.
18    /// See [`Neo4jLabsPlugin::ApocCore`] for the officially supported variant.
19    /// As of `5.0` APOC has been split into separate repositories, one being the main, officially supported, `APOC Core` and `APOC Extended` Library.
20    ///
21    /// The APOC plugin provides provides access to user-defined procedures and functions which extend the use of the Cypher query language into areas such as data integration, graph algorithms, and data conversion..
22    /// Please see [the APOC Extended documentation] for furhter details
23    ///
24    /// [Cypher query language]: https://neo4j.com/docs/cypher-manual/current/introduction/
25    /// [the APOC Extended documentation]: https://neo4j.com/docs/apoc/current/
26    Apoc,
27    /// APOC (Awesome Procedures on Cypher) **Core** are battle hardened procedures and functions that don’t have external dependencies or require configuration.
28    /// This is also the based of the functionality available in Neo4j AuraDB which lists the available APOC surface in their docs..
29    /// See [`Neo4jLabsPlugin::Apoc`] for the variant maintained by the community.
30    /// As of `5.0` APOC has been split into separate repositories, one being the main, officially supported, `APOC Core` and `APOC Extended` Library.
31    ///
32    /// The APOC plugin provides provides access to user-defined procedures and functions which extend the use of the [Cypher query language] into areas such as data integration, graph algorithms, and data conversion..
33    /// Please see [the APOC Core documentation] for furhter details
34    ///
35    /// [Cypher query language]: https://neo4j.com/docs/cypher-manual/current/introduction/
36    /// [the APOC Core documentation]: https://neo4j.com/docs/aura/platform/apoc/
37    ApocCore,
38    /// Bloom is a graph exploration application for visually interacting with graph data.
39    /// Please see [their documentation](https://neo4j.com/docs/bloom-user-guide/current/) for furhter details
40    Bloom,
41    /// Allows integration of Kafka and other streaming solutions with Neo4j.
42    /// Either to ingest data into the graph from other sources.
43    /// Or to send update events (change data capture - CDC) to the event log for later consumption.
44    /// Please see [their documentation](https://neo4j.com/docs/kafka-streams/) for furhter details
45    ///
46    /// <div class="warning">
47    ///
48    /// The Kafka Connect Neo4j Connector is the recommended method to integrate Kafka with Neo4j, as Neo4j Streams is no longer under active development and will not be supported after version 4.4 of Neo4j.
49    /// The most recent version of the Kafka Connect Neo4j Connector [can be found here](https://neo4j.com/docs/kafka).
50    ///
51    /// </div>
52    Streams,
53    /// Graph Data Science (GDS) library provides efficiently implemented, parallel versions of common graph algorithms, exposed as Cypher procedures. Additionally, GDS includes machine learning pipelines to train predictive supervised models to solve graph problems, such as predicting missing relationships..
54    /// Please see [their documentation](https://neo4j.com/docs/graph-data-science/current/) for furhter details
55    GraphDataScience,
56    /// neosemantics (n10s) is a plugin that enables the use of RDF and its associated vocabularies like (OWL,RDFS,SKOS and others) in Neo4j.
57    /// [RDF is a W3C standard model](https://www.w3.org/RDF/) for data interchange.
58    /// You can use n10s to build integrations with RDF-generating / RDF-consuming components.
59    /// You can also use it to validate your graph against constraints expressed in [SHACL](https://www.w3.org/TR/shacl/) or to run basic inferencing.
60    /// Please see [their documentation](https://neo4j.com/labs/neosemantics/) for furhter details
61    NeoSemantics,
62    /// Allows specifying other plugins
63    Custom(String),
64}
65
66impl std::fmt::Display for Neo4jLabsPlugin {
67    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            Self::Apoc => formatter.pad("apoc"),
70            Self::ApocCore => formatter.pad("apoc-core"),
71            Self::Bloom => formatter.pad("bloom"),
72            Self::Streams => formatter.pad("streams"),
73            Self::GraphDataScience => formatter.pad("graph-data-science"),
74            Self::NeoSemantics => formatter.pad("n10s"),
75            Self::Custom(plugin_name) => formatter.pad(plugin_name),
76        }
77    }
78}
79
80/// Neo4j image for [testcontainers](https://crates.io/crates/testcontainers).
81///
82/// This image is based on the official [Neo4j](https://hub.docker.com/_/neo4j) image.
83/// The default user is `neo4j` and the default password is `neo`.
84/// The default version is `5`.
85///
86/// # Example
87///
88/// ```rust,no_run
89/// use testcontainers_modules::{neo4j::Neo4j, testcontainers::runners::SyncRunner};
90///
91/// let container = Neo4j::default().start().unwrap();
92/// let uri = format!(
93///     "bolt://{}:{}",
94///     container.get_host().unwrap(),
95///     container.image().bolt_port_ipv4().unwrap()
96/// );
97/// let auth_user = container.image().user();
98/// let auth_pass = container.image().password();
99/// // connect to Neo4j with the uri, user and pass
100/// ```
101///
102/// # Neo4j Version
103///
104/// The version of the image can be set with the `NEO4J_VERSION_TAG` environment variable.
105/// The default version is `5`.
106/// The available versions can be found on [Docker Hub](https://hub.docker.com/_/neo4j/tags).
107///
108/// The used version can be retrieved with the `version` method.
109///
110/// # Auth
111///
112/// The default user is `neo4j` and the default password is `neo`.
113///
114/// The used user can be retrieved with the `user` method.
115/// The used password can be retrieved with the `pass` method.
116///
117/// # Environment variables
118///
119/// The following environment variables are supported:
120///   * `NEO4J_VERSION_TAG`: The default version of the image to use.
121///   * `NEO4J_TEST_USER`: The default user to use for authentication.
122///   * `NEO4J_TEST_PASS`: The default password to use for authentication.
123///
124/// # Neo4j Labs Plugins
125///
126/// Neo4j offers built-in support for Neo4j Labs plugins.
127/// The method `with_neo4j_labs_plugin` can be used to define them.
128///
129/// Supported plugins are APOC, APOC Core, Bloom, Streams, Graph Data Science, and Neo Semantics.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct Neo4j {
132    version: Value,
133    user: Option<Value>,
134    pass: Option<Value>,
135    plugins: BTreeSet<Neo4jLabsPlugin>,
136}
137
138impl Neo4j {
139    const DEFAULT_USER: &'static str = "neo4j";
140    const DEFAULT_PASS: &'static str = "password";
141    const DEFAULT_VERSION_TAG: &'static str = "5";
142
143    /// Create a new instance of a Neo4j image.
144    #[must_use]
145    pub fn new() -> Self {
146        Self {
147            version: Cow::Borrowed(Self::DEFAULT_VERSION_TAG),
148            user: Some(Cow::Borrowed(Self::DEFAULT_USER)),
149            pass: Some(Cow::Borrowed(Self::DEFAULT_PASS)),
150            plugins: BTreeSet::new(),
151        }
152    }
153
154    /// Set the Neo4j version to use.
155    /// The value must be an existing Neo4j version tag.
156    pub fn with_version(mut self, version: impl Into<Value>) -> Self {
157        self.version = version.into();
158        self
159    }
160
161    /// Set the username to use.
162    #[must_use]
163    pub fn with_user(mut self, user: impl Into<Value>) -> Self {
164        self.user = Some(user.into());
165        self
166    }
167
168    /// Set the password to use.
169    #[must_use]
170    pub fn with_password(mut self, pass: impl Into<Value>) -> Self {
171        self.pass = Some(pass.into());
172        self
173    }
174
175    /// Do not use any authentication on the testcontainer.
176    ///
177    /// Setting this will override any prior usages of [`Self::with_user`] and
178    /// [`Self::with_password`].
179    pub fn without_authentication(mut self) -> Self {
180        self.user = None;
181        self.pass = None;
182        self
183    }
184
185    /// Add Neo4j lab plugins to get started with the database.
186    #[must_use]
187    pub fn with_neo4j_labs_plugin(mut self, plugins: &[Neo4jLabsPlugin]) -> Self {
188        self.plugins.extend(plugins.iter().cloned());
189        self
190    }
191}
192
193type Value = Cow<'static, str>;
194
195impl Default for Neo4j {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201/// The actual Neo4j testcontainers image type which is returned by `container.image()`
202pub struct Neo4jImage {
203    version: String,
204    auth: Option<(String, String)>,
205    env_vars: HashMap<String, String>,
206    state: RwLock<Option<ContainerState>>,
207}
208
209impl Neo4jImage {
210    /// Return the version of the Neo4j image.
211    #[must_use]
212    pub fn version(&self) -> &str {
213        &self.version
214    }
215
216    /// Return the user/password authentication tuple of the Neo4j server.
217    /// If no authentication is set, `None` is returned.
218    #[must_use]
219    pub fn auth(&self) -> Option<(&str, &str)> {
220        self.auth
221            .as_ref()
222            .map(|(user, pass)| (user.as_str(), pass.as_str()))
223    }
224
225    /// Return the user of the Neo4j server.
226    /// If no authentication is set, `None` is returned.
227    #[must_use]
228    pub fn user(&self) -> Option<&str> {
229        self.auth().map(|(user, _)| user)
230    }
231
232    /// Return the password of the Neo4j server.
233    /// If no authentication is set, `None` is returned.
234    #[must_use]
235    pub fn password(&self) -> Option<&str> {
236        self.auth().map(|(_, pass)| pass)
237    }
238
239    /// Return the port to connect to the Neo4j server via Bolt over IPv4.
240    pub fn bolt_port_ipv4(&self) -> Result<u16, TestcontainersError> {
241        self.state
242            .read()
243            .map_err(|_| TestcontainersError::other("failed to lock the sate of Neo4J"))?
244            .as_ref()
245            .ok_or_else(|| {
246                TestcontainersError::other("Container must be started before port can be retrieved")
247            })?
248            .host_port_ipv4(7687.tcp())
249    }
250
251    /// Return the port to connect to the Neo4j server via Bolt over IPv6.
252    pub fn bolt_port_ipv6(&self) -> Result<u16, TestcontainersError> {
253        self.state
254            .read()
255            .map_err(|_| TestcontainersError::other("failed to lock the sate of Neo4J"))?
256            .as_ref()
257            .ok_or_else(|| {
258                TestcontainersError::other("Container must be started before port can be retrieved")
259            })?
260            .host_port_ipv6(7687.tcp())
261    }
262
263    /// Return the port to connect to the Neo4j server via HTTP over IPv4.
264    pub fn http_port_ipv4(&self) -> Result<u16, TestcontainersError> {
265        self.state
266            .read()
267            .map_err(|_| TestcontainersError::other("failed to lock the sate of Neo4J"))?
268            .as_ref()
269            .ok_or_else(|| {
270                TestcontainersError::other("Container must be started before port can be retrieved")
271            })?
272            .host_port_ipv4(7474.tcp())
273    }
274
275    /// Return the port to connect to the Neo4j server via HTTP over IPv6.
276    pub fn http_port_ipv6(&self) -> Result<u16, TestcontainersError> {
277        self.state
278            .read()
279            .map_err(|_| TestcontainersError::other("failed to lock the sate of Neo4J"))?
280            .as_ref()
281            .ok_or_else(|| {
282                TestcontainersError::other("Container must be started before port can be retrieved")
283            })?
284            .host_port_ipv6(7474.tcp())
285    }
286}
287
288impl Image for Neo4jImage {
289    fn name(&self) -> &str {
290        "neo4j"
291    }
292
293    fn tag(&self) -> &str {
294        &self.version
295    }
296
297    fn ready_conditions(&self) -> Vec<WaitFor> {
298        vec![
299            WaitFor::message_on_stdout("Bolt enabled on"),
300            WaitFor::message_on_stdout("Started."),
301        ]
302    }
303
304    fn env_vars(
305        &self,
306    ) -> impl IntoIterator<Item = (impl Into<Cow<'_, str>>, impl Into<Cow<'_, str>>)> {
307        &self.env_vars
308    }
309
310    fn exec_after_start(
311        &self,
312        cs: ContainerState,
313    ) -> Result<Vec<testcontainers::core::ExecCommand>, TestcontainersError> {
314        self.state
315            .write()
316            .map_err(|_| TestcontainersError::other("failed to lock the sate of Neo4J"))?
317            .replace(cs);
318        Ok(Vec::new())
319    }
320}
321
322impl Neo4j {
323    fn auth_env(&self) -> impl IntoIterator<Item = (String, String)> {
324        let auth = self
325            .user
326            .as_ref()
327            .and_then(|user| self.pass.as_ref().map(|pass| format!("{user}/{pass}")))
328            .unwrap_or_else(|| "none".to_owned());
329        Some(("NEO4J_AUTH".to_owned(), auth))
330    }
331
332    fn plugins_env(&self) -> impl IntoIterator<Item = (String, String)> {
333        if self.plugins.is_empty() {
334            return None;
335        }
336
337        let plugin_names = self
338            .plugins
339            .iter()
340            .map(|p| format!("\"{p}\""))
341            .collect::<Vec<String>>()
342            .join(",");
343
344        let plugin_definition = format!("[{plugin_names}]");
345
346        Some(("NEO4JLABS_PLUGINS".to_owned(), plugin_definition))
347    }
348
349    fn conf_env(&self) -> impl IntoIterator<Item = (String, String)> {
350        let pass = self.pass.as_ref()?;
351
352        if pass.len() < 8 {
353            Some((
354                "NEO4J_dbms_security_auth__minimum__password__length".to_owned(),
355                pass.len().to_string(),
356            ))
357        } else {
358            None
359        }
360    }
361
362    fn build(self) -> Neo4jImage {
363        let mut env_vars = HashMap::new();
364
365        for (key, value) in self.auth_env() {
366            env_vars.insert(key, value);
367        }
368
369        for (key, value) in self.plugins_env() {
370            env_vars.insert(key, value);
371        }
372
373        for (key, value) in self.conf_env() {
374            env_vars.insert(key, value);
375        }
376
377        let auth = self
378            .user
379            .and_then(|user| self.pass.map(|pass| (user.into_owned(), pass.into_owned())));
380
381        let version = self.version.into_owned();
382
383        Neo4jImage {
384            version,
385            auth,
386            env_vars,
387            state: RwLock::new(None),
388        }
389    }
390}
391
392impl From<Neo4j> for Neo4jImage {
393    fn from(neo4j: Neo4j) -> Self {
394        neo4j.build()
395    }
396}
397
398impl From<Neo4j> for ContainerRequest<Neo4jImage> {
399    fn from(neo4j: Neo4j) -> Self {
400        Self::from(neo4j.build())
401    }
402}
403
404impl std::fmt::Debug for Neo4jImage {
405    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406        f.debug_struct("Neo4jImage")
407            .field("version", &self.version)
408            .field("auth", &self.auth())
409            .field("env_vars", &self.env_vars)
410            .finish()
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use neo4rs::Graph;
417
418    use super::*;
419    use crate::testcontainers::runners::AsyncRunner;
420
421    #[test]
422    fn set_valid_version() {
423        let neo4j = Neo4j::new().with_version("4.2.0").build();
424        assert_eq!(neo4j.version, "4.2.0");
425    }
426
427    #[test]
428    fn set_partial_version() {
429        let neo4j = Neo4j::new().with_version("4.2").build();
430        assert_eq!(neo4j.version, "4.2");
431
432        let neo4j = Neo4j::new().with_version("4").build();
433        assert_eq!(neo4j.version, "4");
434    }
435
436    #[test]
437    fn set_user() {
438        let neo4j = Neo4j::new().with_user("Benutzer").build();
439        assert_eq!(neo4j.user(), Some("Benutzer"));
440        assert_eq!(neo4j.auth(), Some(("Benutzer", "password")));
441        assert_eq!(
442            neo4j.env_vars.get("NEO4J_AUTH").unwrap(),
443            "Benutzer/password"
444        );
445    }
446
447    #[test]
448    fn set_password() {
449        let neo4j = Neo4j::new().with_password("Passwort").build();
450        assert_eq!(neo4j.password(), Some("Passwort"));
451        assert_eq!(neo4j.auth(), Some(("neo4j", "Passwort")));
452        assert_eq!(neo4j.env_vars.get("NEO4J_AUTH").unwrap(), "neo4j/Passwort");
453    }
454
455    #[test]
456    fn set_short_password() {
457        let neo4j = Neo4j::new().with_password("1337").build();
458        assert_eq!(neo4j.password(), Some("1337"));
459        assert_eq!(neo4j.auth(), Some(("neo4j", "1337")));
460        assert_eq!(
461            neo4j
462                .env_vars
463                .get("NEO4J_dbms_security_auth__minimum__password__length")
464                .unwrap(),
465            "4"
466        );
467    }
468
469    #[test]
470    fn disable_auth() {
471        let neo4j = Neo4j::new().without_authentication().build();
472        assert_eq!(neo4j.password(), None);
473        assert_eq!(neo4j.user(), None);
474        assert_eq!(neo4j.auth(), None);
475        assert_eq!(neo4j.env_vars.get("NEO4J_AUTH").unwrap(), "none");
476    }
477
478    #[test]
479    fn single_plugin_definition() {
480        let neo4j = Neo4j::new()
481            .with_neo4j_labs_plugin(&[Neo4jLabsPlugin::Apoc])
482            .build();
483        assert_eq!(
484            neo4j.env_vars.get("NEO4JLABS_PLUGINS").unwrap(),
485            "[\"apoc\"]"
486        );
487    }
488
489    #[test]
490    fn multiple_plugin_definition() {
491        let neo4j = Neo4j::new()
492            .with_neo4j_labs_plugin(&[Neo4jLabsPlugin::Apoc, Neo4jLabsPlugin::Bloom])
493            .build();
494        assert_eq!(
495            neo4j.env_vars.get("NEO4JLABS_PLUGINS").unwrap(),
496            "[\"apoc\",\"bloom\"]"
497        );
498    }
499
500    #[test]
501    fn multiple_wiht_plugin_calls() {
502        let neo4j = Neo4j::new()
503            .with_neo4j_labs_plugin(&[Neo4jLabsPlugin::Apoc])
504            .with_neo4j_labs_plugin(&[Neo4jLabsPlugin::Bloom])
505            .with_neo4j_labs_plugin(&[Neo4jLabsPlugin::Apoc])
506            .build();
507        assert_eq!(
508            neo4j.env_vars.get("NEO4JLABS_PLUGINS").unwrap(),
509            "[\"apoc\",\"bloom\"]"
510        );
511    }
512
513    #[tokio::test]
514    async fn it_works() -> Result<(), Box<dyn std::error::Error + 'static>> {
515        let container = Neo4j::default().start().await?;
516
517        let uri = format!(
518            "bolt://{}:{}",
519            container.get_host().await?,
520            container.image().bolt_port_ipv4()?
521        );
522
523        let auth_user = container.image().user().expect("default user");
524        let auth_pass = container.image().password().expect("default password");
525
526        let graph = Graph::new(uri, auth_user, auth_pass).await.unwrap();
527        let mut result = graph.execute(neo4rs::query("RETURN 1")).await.unwrap();
528        let row = result.next().await.unwrap().unwrap();
529        let value: i64 = row.get("1").unwrap();
530        assert_eq!(1, value);
531        Ok(())
532    }
533}