diff --git a/docs/src/main/asciidoc/neo4j.adoc b/docs/src/main/asciidoc/neo4j.adoc
index 2fcb8348c1496..62f63d64d8f5b 100644
--- a/docs/src/main/asciidoc/neo4j.adoc
+++ b/docs/src/main/asciidoc/neo4j.adoc
@@ -167,6 +167,7 @@ The Neo4j driver can be configured with standard Quarkus properties:
[source,properties]
.src/main/resources/application.properties
----
+# Those are the default values and are implicitly assumed
quarkus.neo4j.uri = bolt://localhost:7687
quarkus.neo4j.authentication.username = neo4j
quarkus.neo4j.authentication.password = secret
@@ -176,6 +177,24 @@ You'll recognize the authentication here that you passed on to the docker comman
Having done that, the driver is ready to use, there are however other configuration options, detailed below.
+[[dev-services]]
+=== Dev Services (Configuration Free Databases)
+
+Quarkus supports a feature called Dev Services that allows you to create various datasources without any config.
+In the case of Neo4j this support applies to the single Neo4j driver instance.
+Dev Services will bring up a Neo4j container if you didn't explicit add the default values or configured custom values for
+any of `quarkus.neo4j.uri`, `quarkus.neo4j.authentication.username` or `quarkus.neo4j.authentication.password`.
+If Neo4j seems to be reachable via the default properties, Dev Services will also step back.
+
+Otherwise, Quarkus will automatically start a Neo4j container when running tests or dev-mode,
+and automatically configure the connection.
+
+When running the production version of the application, the Neo4j connection need to be configured as normal,
+so if you want to include a production database config in your `application.properties` and continue to use Dev Services
+we recommend that you use the `%prod.` profile to define your Neo4j settings.
+
+include::{generated-dir}/config/quarkus-neo4j-config-group-dev-services-build-time-config.adoc[opts=optional, leveloffset=+1]
+
== Using the driver
=== General remarks
diff --git a/extensions/neo4j/deployment/pom.xml b/extensions/neo4j/deployment/pom.xml
index e299429a06662..c1a26e9eab907 100644
--- a/extensions/neo4j/deployment/pom.xml
+++ b/extensions/neo4j/deployment/pom.xml
@@ -29,6 +29,36 @@
io.quarkus
quarkus-neo4j
+
+ org.testcontainers
+ neo4j
+
+
+ junit
+ junit
+
+
+
+
+ io.quarkus
+ quarkus-devservices-common
+
+
+
+ io.quarkus
+ quarkus-junit5-internal
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
@@ -45,6 +75,15 @@
+
+
+ maven-surefire-plugin
+
+
+
+
+
+
diff --git a/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/BoltHandshaker.java b/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/BoltHandshaker.java
new file mode 100644
index 0000000000000..687074e94cb3b
--- /dev/null
+++ b/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/BoltHandshaker.java
@@ -0,0 +1,77 @@
+package io.quarkus.neo4j.deployment;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.time.Duration;
+
+/**
+ * This implements the protocol version negotiation of bolt. Testing to see if in address will respond to this is a
+ * quick way to find out if it's a running bolt server.
+ *
+ * This class first appeared in https://github.com/michael-simons/junit-jupiter-causal-cluster-testcontainer-extension
+ * by Andrew Jefferson and Michael Simons
+ */
+final class BoltHandshaker {
+
+ private static final int magicToken = 1616949271;
+
+ // Versions message that cannot be matched because it is all zeros.
+ private static final byte[] versionsMessage = {
+ (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
+ (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
+ (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
+ (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00
+ };
+
+ private final String address;
+ private final int port;
+
+ BoltHandshaker(String address, int port) {
+ this.address = address;
+ this.port = port;
+ }
+
+ private boolean doBoltHandshake(String address, int port, int timeoutMillis) {
+
+ try (Socket socket = new Socket()) {
+
+ // Set the socket timeout for blocking operations
+ socket.setSoTimeout(timeoutMillis);
+
+ // Connects this socket to the server (also with the specified timeout value).
+ socket.connect(new InetSocketAddress(address, port), timeoutMillis);
+
+ DataOutputStream dOut = new DataOutputStream(socket.getOutputStream());
+ DataInputStream dIn = new DataInputStream(socket.getInputStream());
+
+ // Send magic token (0x6060B017)
+ dOut.writeInt(magicToken);
+ dOut.flush();
+
+ // Send 4 supported versions
+ // Except we don't support any versions and communicate that by sending all zeros
+ dOut.write(versionsMessage);
+ dOut.flush();
+
+ // Receive agreed version
+ // It should be 0 because there are no possible versions we can agree on
+ int response = dIn.readInt();
+ assert response == 0;
+
+ // Because we cannot agree on a version the server should close its side of the connection
+ // resulting in EOF (-1) on all subsequent reads.
+ return dIn.read() == -1;
+ } catch (IOException exception) {
+ // Return false if handshake fails
+ return false;
+ }
+ }
+
+ boolean isBoltPortReachable(Duration timeout) {
+ int timeoutMillis = Math.toIntExact(timeout.toMillis());
+ return doBoltHandshake(address, port, timeoutMillis);
+ }
+}
diff --git a/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/DevServicesBuildTimeConfig.java b/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/DevServicesBuildTimeConfig.java
new file mode 100644
index 0000000000000..2b5cc03f60782
--- /dev/null
+++ b/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/DevServicesBuildTimeConfig.java
@@ -0,0 +1,32 @@
+package io.quarkus.neo4j.deployment;
+
+import java.util.Map;
+import java.util.Optional;
+
+import io.quarkus.runtime.annotations.ConfigGroup;
+import io.quarkus.runtime.annotations.ConfigItem;
+
+@ConfigGroup
+public class DevServicesBuildTimeConfig {
+
+ /**
+ * If DevServices has been explicitly enabled or disabled. DevServices is generally enabled
+ * by default, unless there is an existing configuration present.
+ * When DevServices is enabled Quarkus will attempt to automatically configure and start
+ * a database when running in Dev or Test mode.
+ */
+ @ConfigItem
+ public Optional enabled = Optional.empty();
+
+ /**
+ * The container image name to use, for container based DevServices providers.
+ */
+ @ConfigItem(defaultValue = "neo4j:4.3")
+ public String imageName;
+
+ /**
+ * Additional environment entries that can be added to the container before its start.
+ */
+ @ConfigItem
+ public Map additionalEnv;
+}
diff --git a/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/Neo4jBuildTimeConfig.java b/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/Neo4jBuildTimeConfig.java
index c968d7f59eae6..a02812ef7a9d3 100644
--- a/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/Neo4jBuildTimeConfig.java
+++ b/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/Neo4jBuildTimeConfig.java
@@ -6,9 +6,16 @@
@ConfigRoot(name = "neo4j", phase = ConfigPhase.BUILD_TIME)
public class Neo4jBuildTimeConfig {
+
/**
- * Whether or not an health check is published in case the smallrye-health extension is present.
+ * Whether a health check is published in case the smallrye-health extension is present.
*/
@ConfigItem(name = "health.enabled", defaultValue = "true")
public boolean healthEnabled;
+
+ /**
+ * Configuration for DevServices. DevServices allows Quarkus to automatically start a Neo4j instance in dev and test mode.
+ */
+ @ConfigItem
+ public DevServicesBuildTimeConfig devservices;
}
diff --git a/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/Neo4jDevServiceBuildItem.java b/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/Neo4jDevServiceBuildItem.java
new file mode 100644
index 0000000000000..a4df6e02fbb20
--- /dev/null
+++ b/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/Neo4jDevServiceBuildItem.java
@@ -0,0 +1,6 @@
+package io.quarkus.neo4j.deployment;
+
+import io.quarkus.builder.item.SimpleBuildItem;
+
+public final class Neo4jDevServiceBuildItem extends SimpleBuildItem {
+}
diff --git a/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/Neo4jDevServicesProcessor.java b/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/Neo4jDevServicesProcessor.java
new file mode 100644
index 0000000000000..543cdd453496a
--- /dev/null
+++ b/extensions/neo4j/deployment/src/main/java/io/quarkus/neo4j/deployment/Neo4jDevServicesProcessor.java
@@ -0,0 +1,188 @@
+package io.quarkus.neo4j.deployment;
+
+import java.io.Closeable;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.BooleanSupplier;
+
+import org.jboss.logging.Logger;
+import org.testcontainers.containers.Neo4jContainer;
+
+import io.quarkus.deployment.IsNormal;
+import io.quarkus.deployment.annotations.BuildProducer;
+import io.quarkus.deployment.annotations.BuildStep;
+import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem;
+import io.quarkus.deployment.builditem.DevServicesConfigResultBuildItem;
+import io.quarkus.deployment.builditem.LaunchModeBuildItem;
+import io.quarkus.deployment.console.ConsoleInstalledBuildItem;
+import io.quarkus.deployment.console.StartupLogCompressor;
+import io.quarkus.deployment.dev.devservices.GlobalDevServicesConfig;
+import io.quarkus.deployment.logging.LoggingSetupBuildItem;
+import io.quarkus.runtime.configuration.ConfigUtils;
+
+class Neo4jDevServicesProcessor {
+
+ private static final Logger log = Logger.getLogger("io.quarkus.neo4j.deployment");
+
+ private static final String NEO4J_URI = "quarkus.neo4j.uri";
+ private static final String NEO4J_USER_PROP = "quarkus.neo4j.authentication.username";
+ private static final String NEO4J_PASSWORD_PROP = "quarkus.neo4j.authentication.password";
+
+ static volatile Closeable closeable;
+ static volatile Neo4jDevServiceConfig runningConfiguration;
+ static volatile boolean first = true;
+
+ static final class IsDockerWorking implements BooleanSupplier {
+
+ private final io.quarkus.deployment.IsDockerWorking delegate = new io.quarkus.deployment.IsDockerWorking(true);
+
+ @Override
+ public boolean getAsBoolean() {
+
+ return delegate.getAsBoolean();
+ }
+ }
+
+ static final class BoldHandshakeOnDefaultAddressIsPossible implements BooleanSupplier {
+
+ private final BoltHandshaker boltHandshaker = new BoltHandshaker("localhost", 7687);
+
+ @Override
+ public boolean getAsBoolean() {
+
+ var boldIsReachable = Boolean.getBoolean("quarkus.neo4j.devservices.assumeBoltIsReachable")
+ || boltHandshaker.isBoltPortReachable(Duration.ofSeconds(5));
+ if (boldIsReachable && log.isDebugEnabled()) {
+ log.info("Not starting Dev Services for Neo4j, as the default config points to a reachable address.");
+ }
+ return boldIsReachable;
+ }
+ }
+
+ @BuildStep(onlyIfNot = { IsNormal.class, BoldHandshakeOnDefaultAddressIsPossible.class }, onlyIf = {
+ IsDockerWorking.class,
+ GlobalDevServicesConfig.Enabled.class })
+ public Neo4jDevServiceBuildItem startNeo4jDevService(
+ LaunchModeBuildItem launchMode,
+ Neo4jBuildTimeConfig neo4jBuildTimeConfig,
+ BuildProducer devServicePropertiesProducer,
+ Optional consoleInstalledBuildItem,
+ CuratedApplicationShutdownBuildItem closeBuildItem,
+ LoggingSetupBuildItem loggingSetupBuildItem) {
+
+ var configuration = new Neo4jDevServiceConfig(neo4jBuildTimeConfig.devservices);
+
+ if (closeable != null) {
+ if (configuration.equals(runningConfiguration)) {
+ return null;
+ }
+ shutdownNeo4j();
+ runningConfiguration = null;
+ }
+
+ var compressor = new StartupLogCompressor(
+ (launchMode.isTest() ? "(test) " : "") + "Neo4j Dev Services Starting:", consoleInstalledBuildItem,
+ loggingSetupBuildItem);
+ try {
+ var neo4jContainer = startNeo4j(configuration, launchMode);
+ if (neo4jContainer != null) {
+ devServicePropertiesProducer.produce(
+ new DevServicesConfigResultBuildItem(NEO4J_URI, neo4jContainer.getBoltUrl()));
+ devServicePropertiesProducer.produce(new DevServicesConfigResultBuildItem(NEO4J_USER_PROP, "neo4j"));
+ devServicePropertiesProducer.produce(new DevServicesConfigResultBuildItem(NEO4J_PASSWORD_PROP,
+ neo4jContainer.getAdminPassword()));
+
+ log.infof("Dev Services started a Neo4j container reachable at %s.", neo4jContainer.getBoltUrl());
+
+ closeable = neo4jContainer::close;
+ }
+ } catch (Throwable t) {
+ compressor.closeAndDumpCaptured();
+ throw new RuntimeException(t);
+ }
+
+ // Configure the watch dog
+ if (first) {
+ first = false;
+ Runnable closeTask = () -> {
+ if (closeable != null) {
+ shutdownNeo4j();
+ log.info("Dev Services for Neo4j shut down.");
+ }
+ first = true;
+ closeable = null;
+ runningConfiguration = null;
+ };
+ closeBuildItem.addCloseTask(closeTask, true);
+ }
+ runningConfiguration = configuration;
+
+ return new Neo4jDevServiceBuildItem();
+ }
+
+ private Neo4jContainer> startNeo4j(Neo4jDevServiceConfig configuration, LaunchModeBuildItem launchMode) {
+
+ if (!configuration.devServicesEnabled) {
+ log.debug("Not starting Dev Services for Neo4j, as it has been disabled in the config.");
+ return null;
+ }
+
+ // Check if Neo4j URL or password is set to explicitly
+ if (ConfigUtils.isPropertyPresent(NEO4J_URI) || ConfigUtils.isPropertyPresent(NEO4J_USER_PROP)
+ || ConfigUtils.isPropertyPresent(NEO4J_PASSWORD_PROP)) {
+ log.debug("Not starting Dev Services for Neo4j, as there is explicit configuration present.");
+ return null;
+ }
+
+ var neo4jContainer = new Neo4jContainer<>(configuration.imageName);
+ configuration.additionalEnv.forEach(neo4jContainer::addEnv);
+ neo4jContainer.start();
+ return neo4jContainer;
+ }
+
+ private void shutdownNeo4j() {
+ if (closeable != null) {
+ try {
+ closeable.close();
+ } catch (Throwable e) {
+ log.error("Failed to stop Neo4j container", e);
+ } finally {
+ closeable = null;
+ }
+ }
+ }
+
+ private static final class Neo4jDevServiceConfig {
+ final boolean devServicesEnabled;
+ final String imageName;
+ final Map additionalEnv;
+
+ Neo4jDevServiceConfig(DevServicesBuildTimeConfig devServicesConfig) {
+ this.devServicesEnabled = devServicesConfig.enabled.orElse(true);
+ this.imageName = devServicesConfig.imageName;
+ this.additionalEnv = new HashMap<>(devServicesConfig.additionalEnv);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Neo4jDevServiceConfig that = (Neo4jDevServiceConfig) o;
+ return devServicesEnabled == that.devServicesEnabled && imageName.equals(that.imageName)
+ && additionalEnv.equals(
+ that.additionalEnv);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(devServicesEnabled, imageName, additionalEnv);
+ }
+ }
+}
diff --git a/extensions/neo4j/deployment/src/test/java/io/quarkus/neo4j/deployment/Neo4jDevModeTests.java b/extensions/neo4j/deployment/src/test/java/io/quarkus/neo4j/deployment/Neo4jDevModeTests.java
new file mode 100644
index 0000000000000..b9187a541600c
--- /dev/null
+++ b/extensions/neo4j/deployment/src/test/java/io/quarkus/neo4j/deployment/Neo4jDevModeTests.java
@@ -0,0 +1,142 @@
+package io.quarkus.neo4j.deployment;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+
+import java.util.logging.LogRecord;
+
+import javax.inject.Inject;
+
+import org.jboss.shrinkwrap.api.ShrinkWrap;
+import org.jboss.shrinkwrap.api.spec.JavaArchive;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.neo4j.driver.Driver;
+import org.neo4j.driver.exceptions.ServiceUnavailableException;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import io.quarkus.test.QuarkusUnitTest;
+
+public class Neo4jDevModeTests {
+
+ @Testcontainers(disabledWithoutDocker = true)
+ static class DevServicesShouldStartNeo4j {
+
+ @RegisterExtension
+ static QuarkusUnitTest test = new QuarkusUnitTest()
+ .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class))
+ .setLogRecordPredicate(record -> true)
+ .withConfigurationResource("application.properties")
+ .assertLogRecords(records -> assertThat(records).extracting(LogRecord::getMessage)
+ .contains("Dev Services started a Neo4j container reachable at %s."));
+
+ @Inject
+ Driver driver;
+
+ @Test
+ public void shouldBeAbleToConnect() {
+
+ assertThatNoException().isThrownBy(() -> driver.verifyConnectivity());
+
+ }
+ }
+
+ @Testcontainers(disabledWithoutDocker = true)
+ static class WorkingWithDifferentImageAndAdditionalEnv {
+
+ @RegisterExtension
+ static QuarkusUnitTest test = new QuarkusUnitTest()
+ .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class))
+ .setLogRecordPredicate(record -> true)
+ .withConfigurationResource("application.properties")
+ .overrideConfigKey("quarkus.neo4j.devservices.image-name", "neo4j:4.3-enterprise")
+ .overrideConfigKey("quarkus.neo4j.devservices.additional-env.NEO4J_ACCEPT_LICENSE_AGREEMENT", "yes")
+ .assertLogRecords(records -> assertThat(records).extracting(LogRecord::getMessage)
+ .contains("Dev Services started a Neo4j container reachable at %s."));
+
+ @Inject
+ Driver driver;
+
+ @Test
+ public void shouldBeAbleToConnect() {
+
+ assertThatNoException().isThrownBy(() -> driver.verifyConnectivity());
+ try (var session = driver.session()) {
+ var cypher = "CALL dbms.components() YIELD versions, name, edition WHERE name = 'Neo4j Kernel' RETURN edition, versions[0] as version";
+ var result = session.run(cypher).single();
+ assertThat(result.get("edition").asString()).isEqualToIgnoringCase("enterprise");
+ }
+ }
+ }
+
+ @Testcontainers(disabledWithoutDocker = true)
+ static class WithLocallyDisabledDevServicesTest {
+
+ @RegisterExtension
+ static QuarkusUnitTest test = new QuarkusUnitTest()
+ .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class))
+ .setLogRecordPredicate(record -> true)
+ .withConfigurationResource("application.properties")
+ .overrideConfigKey("quarkus.neo4j.devservices.enabled", "false")
+ .assertLogRecords(records -> assertThat(records).extracting(LogRecord::getMessage)
+ .contains("Not starting Dev Services for Neo4j, as it has been disabled in the config."));
+
+ @Inject
+ Driver driver;
+
+ @Test
+ public void shouldNotBeAbleToConnect() {
+
+ assertThatExceptionOfType(ServiceUnavailableException.class).isThrownBy(() -> driver.verifyConnectivity());
+ }
+ }
+
+ @Testcontainers(disabledWithoutDocker = true)
+ static class WithExplicitProperty {
+
+ @RegisterExtension
+ static QuarkusUnitTest test = new QuarkusUnitTest()
+ .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class))
+ .setLogRecordPredicate(record -> true)
+ .withConfigurationResource("application.properties")
+ .overrideConfigKey("quarkus.neo4j.uri", "bolt://localhost:7687")
+ .assertLogRecords(records -> assertThat(records).extracting(LogRecord::getMessage)
+ .contains("Not starting Dev Services for Neo4j, as there is explicit configuration present."));
+
+ @Inject
+ Driver driver;
+
+ @Test
+ public void shouldNotBeAbleToConnect() {
+
+ assertThatExceptionOfType(ServiceUnavailableException.class).isThrownBy(() -> driver.verifyConnectivity());
+ }
+ }
+
+ @Testcontainers(disabledWithoutDocker = true)
+ static class WithAlreadyReachableInstance {
+
+ static {
+ // Make our check think that
+ System.setProperty("quarkus.neo4j.devservices.assumeBoltIsReachable", "true");
+ }
+
+ @RegisterExtension
+ static QuarkusUnitTest test = new QuarkusUnitTest()
+ .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class))
+ .setLogRecordPredicate(record -> true)
+ .withConfigurationResource("application.properties")
+ .assertLogRecords(records -> assertThat(records).extracting(LogRecord::getMessage)
+ .contains("Not starting Dev Services for Neo4j, as the default config points to a reachable address."));
+
+ @Inject
+ Driver driver;
+
+ @Test
+ public void shouldNotBeAbleToConnect() {
+
+ assertThatExceptionOfType(ServiceUnavailableException.class).isThrownBy(() -> driver.verifyConnectivity());
+ }
+ }
+}
diff --git a/extensions/neo4j/deployment/src/test/resources/application.properties b/extensions/neo4j/deployment/src/test/resources/application.properties
new file mode 100644
index 0000000000000..cf7bbd37413ba
--- /dev/null
+++ b/extensions/neo4j/deployment/src/test/resources/application.properties
@@ -0,0 +1,8 @@
+# I much prefer just setting this but I was not able to capture
+# debug log with QuarkusUnitTest and the InMemoryLogHandler without...
+quarkus.log.category."io.quarkus.neo4j.deployment".level=DEBUG
+
+# Forcing the whole of Quarkus into logging
+quarkus.log.level=DEBUG
+# And than taming console again
+quarkus.log.console.level=INFO