CASSANDRASC-34: Allow for LoggerHandler to be injected

Currently, `vertxRouter` adds an instance of `LoggerHandler` to the top level route.
This is prescriptive and it doesn't allow for a different implementation of the LoggerHandler
to be injected.

In this commit, `LoggerHandler` is created in the `MainModule` as a singleton and then
injected in the `vertxRouter` method. This allows for a new implementation of the `LoggerHandler`
to be provided.
diff --git a/CHANGES.txt b/CHANGES.txt
index c72ce7d..27ce525 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,6 @@
 1.0.0
 -----
+ * Allow injecting a LoggerHandler to vertxRouter (CASSANDRASC-34)
  * Optionally support multiple cassandra instances in Sidecar (CASSANDRASC-33)
  * Call the start method of CassandraAdaptorDelegate to start periodic health check (CASSANDRASC-32)
  * Avoid having sidecar's health response code depend on Cassandra's health information (CASSANDRASC-29)
diff --git a/gradle.properties b/gradle.properties
index c9d988b..f004357 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,5 +1,5 @@
 version=1.0-SNAPSHOT
 junitVersion=5.4.2
 kubernetesClientVersion=9.0.0
-cassandra40Version=4.0.1
+cassandra40Version=4.0.3
 vertxVersion=4.2.1
diff --git a/src/main/java/org/apache/cassandra/sidecar/MainModule.java b/src/main/java/org/apache/cassandra/sidecar/MainModule.java
index 32c3548..a7eb33d 100644
--- a/src/main/java/org/apache/cassandra/sidecar/MainModule.java
+++ b/src/main/java/org/apache/cassandra/sidecar/MainModule.java
@@ -129,10 +129,10 @@
 
     @Provides
     @Singleton
-    public Router vertxRouter(Vertx vertx)
+    public Router vertxRouter(Vertx vertx, LoggerHandler loggerHandler)
     {
         Router router = Router.router(vertx);
-        router.route().handler(LoggerHandler.create());
+        router.route().handler(loggerHandler);
 
         // Static web assets for Swagger
         StaticHandler swaggerStatic = StaticHandler.create("META-INF/resources/webjars/swagger-ui");
@@ -254,4 +254,11 @@
     {
         return SidecarRateLimiter.create(config.getRateLimitStreamRequestsPerSecond());
     }
+
+    @Provides
+    @Singleton
+    public LoggerHandler loggerHandler()
+    {
+        return LoggerHandler.create();
+    }
 }
diff --git a/src/test/java/org/apache/cassandra/sidecar/LoggerHandlerInjectionTest.java b/src/test/java/org/apache/cassandra/sidecar/LoggerHandlerInjectionTest.java
new file mode 100644
index 0000000..d658ddb
--- /dev/null
+++ b/src/test/java/org/apache/cassandra/sidecar/LoggerHandlerInjectionTest.java
@@ -0,0 +1,147 @@
+package org.apache.cassandra.sidecar;
+
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.slf4j.Logger;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.util.Modules;
+import io.vertx.core.Handler;
+import io.vertx.core.Vertx;
+import io.vertx.core.http.HttpServer;
+import io.vertx.core.http.HttpServerRequest;
+import io.vertx.ext.web.Router;
+import io.vertx.ext.web.RoutingContext;
+import io.vertx.ext.web.client.HttpResponse;
+import io.vertx.ext.web.client.WebClient;
+import io.vertx.ext.web.codec.BodyCodec;
+import io.vertx.ext.web.handler.HttpException;
+import io.vertx.ext.web.handler.LoggerFormatter;
+import io.vertx.ext.web.handler.LoggerHandler;
+import io.vertx.junit5.VertxExtension;
+import io.vertx.junit5.VertxTestContext;
+
+import static io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND;
+import static io.netty.handler.codec.http.HttpResponseStatus.NO_CONTENT;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+@DisplayName("LoggerHandler Injection Test")
+@ExtendWith(VertxExtension.class)
+public class LoggerHandlerInjectionTest
+{
+    private Vertx vertx;
+    private Configuration config;
+    private final Logger logger = mock(Logger.class);
+
+    @BeforeEach
+    void setUp() throws InterruptedException
+    {
+        FakeLoggerHandler loggerHandler = new FakeLoggerHandler(logger);
+        Injector injector = Guice.createInjector(Modules.override(Modules.override(new MainModule()).with(new TestModule()))
+                                                        .with(binder -> binder.bind(LoggerHandler.class).toInstance(loggerHandler)));
+        vertx = injector.getInstance(Vertx.class);
+        config = injector.getInstance(Configuration.class);
+        Router router = injector.getInstance(Router.class);
+
+        router.get("/500-route").handler(p -> {
+            throw new RuntimeException("Fails with 500");
+        });
+
+        router.get("/404-route").handler(p -> {
+            throw new HttpException(NOT_FOUND.code(), "Sorry, it's not here");
+        });
+
+        router.get("/204-route").handler(p -> {
+            throw new HttpException(NO_CONTENT.code(), "Sorry, no content");
+        });
+
+        VertxTestContext context = new VertxTestContext();
+        HttpServer server = injector.getInstance(HttpServer.class);
+        server.listen(config.getPort(), context.succeedingThenComplete());
+
+        context.awaitCompletion(5, TimeUnit.SECONDS);
+    }
+
+    @DisplayName("Should log at error level when the request fails with a 500 code")
+    @Test
+    public void testInjectedLoggerHandlerLogsAtErrorLevel(VertxTestContext testContext)
+    {
+        helper("/500-route", testContext, 500, "Internal Server Error");
+    }
+
+    @DisplayName("Should log at warn level when the request fails with a 404 error")
+    @Test
+    public void testInjectedLoggerHandlerLogsAtWarnLevel(VertxTestContext testContext)
+    {
+        helper("/404-route", testContext, 404, "Not Found");
+    }
+
+    @DisplayName("Should log at info level when the request returns with a 500 error")
+    @Test
+    public void testInjectedLoggerHandlerLogsAtInfoLevel(VertxTestContext testContext)
+    {
+        helper("/204-route", testContext, 204, null);
+    }
+
+    private void helper(String requestURI, VertxTestContext testContext, int expectedStatusCode, String expectedBody)
+    {
+        WebClient client = WebClient.create(vertx);
+        Handler<HttpResponse<String>> responseVerifier = response -> testContext.verify(
+        () -> {
+            assertThat(response.statusCode()).isEqualTo(expectedStatusCode);
+            if (expectedBody == null)
+            {
+                assertThat(response.body()).isNull();
+            }
+            else
+            {
+                assertThat(response.body()).isEqualTo(expectedBody);
+            }
+            testContext.completeNow();
+            verify(logger, times(1)).info("{}", expectedStatusCode);
+        });
+        client.get(config.getPort(), "localhost", requestURI)
+              .as(BodyCodec.string())
+              .ssl(false)
+              .send(testContext.succeeding(responseVerifier));
+    }
+
+    private static class FakeLoggerHandler implements LoggerHandler
+    {
+        private final Logger logger;
+
+        FakeLoggerHandler(Logger logger)
+        {
+            this.logger = logger;
+        }
+
+        @Override
+        public LoggerHandler customFormatter(Function<HttpServerRequest, String> formatter)
+        {
+            return this;
+        }
+
+        @Override
+        public LoggerHandler customFormatter(LoggerFormatter formatter)
+        {
+            return this;
+        }
+
+        @Override
+        public void handle(RoutingContext context)
+        {
+            HttpServerRequest request = context.request();
+            context.addBodyEndHandler(v -> logger.info("{}", request.response().getStatusCode()));
+            context.next();
+        }
+    }
+}