RESTEasy integration with dynamically generated Swagger OpenAPI, Swagger UI and JAX-RS.

This patch introduces JAX-RS based annotation for defining APIs. It removes the manually
created api.yaml (OpenAPI spec) of the API definitions in favor of the dynamically
generated spec based on JAX-RS annotations. It also introduces Swagger UI to browse the
Sidecar APIs and to experiment with them. Finally, it updates the CircleCI workflows
such that the builds are run across both Docker and Machine images. We also gate packaging
builds on success of the compile and test builds. The rationale for running the builds
across both Docker and Machine images is that running the build on a Machine image exposed
a race condition.

Patch by Dinesh Joshi; Reviewed by Jon Haddad and Yifan Cai for CASSANDRASC-22
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 8712204..893c9bc 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -40,6 +40,21 @@
       - run: sudo apt-get install apt-transport-https ca-certificates curl gnupg-agent software-properties-common
 
 jobs:
+  java8_docker:
+    docker:
+     - image: circleci/openjdk:8-jdk-stretch
+    steps:
+     - checkout
+
+     - run: ./gradlew -i clean build --stacktrace
+
+     - store_artifacts:
+         path: build/reports
+         destination: test-reports
+
+     - store_test_results:
+         path: ~/repo/build/test-results/
+
   java8:
     <<: *base_job
 
@@ -53,7 +68,22 @@
       - run: sudo update-java-alternatives -s adoptopenjdk-8-hotspot-amd64 && java -version
 
       # make sure it builds with build steps like swagger docs and dist
-      - run: ./gradlew build --stacktrace
+      - run: ./gradlew -i clean build --stacktrace
+
+      - store_artifacts:
+          path: build/reports
+          destination: test-reports
+
+      - store_test_results:
+          path: ~/repo/build/test-results/
+
+  java11_docker:
+    docker:
+      - image: circleci/openjdk:11-jdk-stretch
+    steps:
+      - checkout
+
+      - run: ./gradlew -i clean build --stacktrace
 
       - store_artifacts:
           path: build/reports
@@ -73,7 +103,7 @@
 
       - run: sudo update-java-alternatives -s adoptopenjdk-11-hotspot-amd64 && java -version
 
-      - run: ./gradlew build --stacktrace
+      - run: ./gradlew -i clean build --stacktrace
 
       - store_artifacts:
           path: build/reports
@@ -84,13 +114,11 @@
 
   # ensures we can build and install deb packages
   deb_build_install:
-    <<: *base_job
+    docker:
+      - image: circleci/openjdk:11-jdk-stretch
     steps:
       - checkout
-      - install_common
-      - install_java:
-          version: adoptopenjdk-11-hotspot
-      - run: ./gradlew buildDeb
+      - run: ./gradlew -i clean buildDeb
       - run: DEBIAN_FRONTEND=noninteractive sudo apt install -y ./build/distributions/cassandra-sidecar*.deb
       - run: test -f /opt/cassandra-sidecar/bin/cassandra-sidecar
 
@@ -100,7 +128,7 @@
     steps:
       - checkout
       - run: yum install -y java-11-openjdk-devel  # the image uses root by default, no need for sudo
-      - run: JAVA_HOME=/usr/lib/jvm/java-11-openjdk ./gradlew buildRpm
+      - run: JAVA_HOME=/usr/lib/jvm/java-11-openjdk ./gradlew -i buildRpm
       - run: yum install -y ./build/distributions/cassandra-sidecar*.rpm
       - run: test -f /opt/cassandra-sidecar/bin/cassandra-sidecar
 
@@ -108,32 +136,51 @@
     <<: *base_job
     steps:
       - checkout
-      - run: ./gradlew jibDockerBuild
+      - run: ./gradlew -i clean jibDockerBuild
 
   docs_build:
-    <<: *base_job
+    docker:
+      - image: circleci/openjdk:11-jdk-stretch
     steps:
       - checkout
-      - install_common
-      - install_java:
-          version: adoptopenjdk-11-hotspot
-      - run: ./gradlew build
+      - run: ./gradlew -i clean build --stacktrace
       - run: test -f build/docs/html5/user.html
 
 workflows:
   version: 2
-
-  test_java_8:
+  build-and-test:
     jobs:
       - java8
-
-  test_java_11:
-    jobs:
+      - java8_docker
       - java11
-
-  test_packaging:
-    jobs:
-      - deb_build_install
-      - rpm_build_install
-      - docker_build
-      - docs_build
\ No newline at end of file
+      - java11_docker
+      - docs_build:
+          requires:
+            - java8
+            - java8_docker
+            - java11
+            - java11_docker
+      - docker_build:
+          requires:
+            - java8
+            - java8_docker
+            - java11
+            - java11_docker
+      - rpm_build_install:
+          requires:
+            - java8
+            - java8_docker
+            - java11
+            - java11_docker
+      - deb_build_install:
+          requires:
+            - java8
+            - java8_docker
+            - java11
+            - java11_docker
+      - docker_build:
+          requires:
+            - java8
+            - java8_docker
+            - java11
+            - java11_docker
diff --git a/.gitignore b/.gitignore
index 810dd6b..b00da53 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,8 +73,6 @@
 
 /.ant-targets-build.xml
 
-# Generated files from the documentation
-src/main/resources/docs/*
 
 src/dist/*
 *.logdir_IS_UNDEFINED
diff --git a/api.yaml b/api.yaml
deleted file mode 100644
index a49e6a8..0000000
--- a/api.yaml
+++ /dev/null
@@ -1,57 +0,0 @@
-openapi: 3.0.0
-
-info:
-  description: Apache Cassandra sidecar
-  version: "1.0.0"
-  title: Apache Cassandra Sidecar API
-  license:
-    name: Apache 2.0
-    url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
-
-tags:
-  - name: visibility
-    description: See status of Cassandra
-  - name: management
-    description: Execute, coordinate, or schedule operations
-
-paths:
-  /api/v1/__health:
-    get:
-      tags:
-        - visibility
-      summary: Check Cassandra Health
-      operationId: health
-      description: |
-        Lists status of Cassandra Daemon and its services
-      responses:
-        '200':
-          description: Current status if Cassandra is up and returning OK status
-          content:
-            application/json:
-              schema:
-                type: object
-                items:
-                  $ref: '#/components/schemas/HealthStatus'
-        '503':
-          description: Health check failed and returning NOT_OK
-          content:
-            application/json:
-              schema:
-                type: object
-                items:
-                  $ref: '#/components/schemas/HealthStatus'
-
-components:
-  schemas:
-    HealthStatus:
-      type: object
-      required:
-        - status
-      properties:
-        status:
-          type: string
-          enum:
-            - 'OK'
-            - 'NOT_OK'
-          description: if reads are able to run through binary interface. 'OK' or 'NOT_OK'
-          example: 'OK'
diff --git a/build.gradle b/build.gradle
index ea5a4bf..6d13ace 100644
--- a/build.gradle
+++ b/build.gradle
@@ -15,6 +15,7 @@
     id 'jacoco'
     id "com.github.spotbugs" version "3.0.0"
     id 'org.hidetake.swagger.generator' version '2.16.0'
+    id "io.swagger.core.v3.swagger-gradle-plugin" version "2.1.2"
 
     // https://github.com/nebula-plugins/gradle-ospackage-plugin/wiki
     id "nebula.ospackage" version "8.3.0"
@@ -83,6 +84,10 @@
     compile 'io.vertx:vertx-dropwizard-metrics:3.8.5'
     compile 'io.vertx:vertx-web-client:3.8.5'
 
+    compile 'io.swagger.core.v3:swagger-jaxrs2:2.1.0'
+    compile 'org.jboss.resteasy:resteasy-vertx:3.1.0.Final'
+    compile group: 'org.jboss.spec.javax.servlet', name: 'jboss-servlet-api_4.0_spec', version: '2.0.0.Final'
+
     // Trying to be exactly compatible with Cassandra's deps
     compile 'org.slf4j:slf4j-api:1.7.25'
     compile 'ch.qos.logback:logback-core:1.2.3'
@@ -90,13 +95,14 @@
 
     compile 'com.datastax.cassandra:cassandra-driver-core:3.6+'
     compile group: 'com.google.inject', name: 'guice', version: '4.2.2'
+
     compile group: 'org.apache.commons', name: 'commons-configuration2', version: '2.7'
+    compile 'org.webjars:swagger-ui:3.10.0'
 
     runtime group: 'commons-beanutils', name: 'commons-beanutils', version: '1.9.3'
     runtime group: 'org.yaml', name: 'snakeyaml', version: '1.26'
 
     jolokia 'org.jolokia:jolokia-jvm:1.6.0:agent'
-    swaggerUI 'org.webjars:swagger-ui:3.10.0'
 
     testCompile group: 'org.cassandraunit', name: 'cassandra-unit-shaded', version: '3.3.0.2'
     testCompile 'com.datastax.cassandra:cassandra-driver-core:3.6.+:tests'
@@ -107,19 +113,6 @@
     integrationTestCompile group: 'com.datastax.oss.simulacron', name: 'simulacron-driver-3x', version: '0.8.10'
 }
 
-swaggerSources {
-    apidoc {
-        inputFile = file('api.yaml')
-        reDoc {
-            outputDir = file('src/main/resources/docs')
-            title = 'Cassandra Sidecar API Documentation'
-        }
-        ui {
-            outputDir = file('src/main/resources/docs/swagger')
-        }
-    }
-}
-
 task copyCodeStyle(type: Copy) {
     from "ide/idea/codeStyleSettings.xml"
     into ".idea"
@@ -222,6 +215,18 @@
     into ""
 }
 
+// This task is defined by swagger-gradle-plugin
+// Resolves project openAPI specification and saves
+// the result in JSON during the build process.
+resolve {
+    outputFileName = 'api'
+    outputFormat = 'JSON'
+    prettyPrint = 'TRUE'
+    classpath = sourceSets.main.runtimeClasspath
+    resourcePackages = ['org.apache.cassandra.sidecar']
+    outputDir = file('build/generated/swagger')
+}
+
 // copyDist gets called on every build
 copyDist.dependsOn installDist, copyJolokia
 check.dependsOn checkstyleMain, checkstyleTest, integrationTest, jacocoTestReport
diff --git a/src/integration/java/org/apache/cassandra/sidecar/HealthServiceIntegrationTest.java b/src/integration/java/org/apache/cassandra/sidecar/HealthServiceIntegrationTest.java
index 1208568..ec8bd4d 100644
--- a/src/integration/java/org/apache/cassandra/sidecar/HealthServiceIntegrationTest.java
+++ b/src/integration/java/org/apache/cassandra/sidecar/HealthServiceIntegrationTest.java
@@ -26,8 +26,10 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.common.util.concurrent.Uninterruptibles;
@@ -42,20 +44,23 @@
 import com.datastax.oss.simulacron.server.BoundNode;
 import com.datastax.oss.simulacron.server.NodePerPortResolver;
 import com.datastax.oss.simulacron.server.Server;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import com.google.inject.util.Modules;
 import io.netty.channel.EventLoopGroup;
 import io.netty.channel.nio.NioEventLoopGroup;
 import io.netty.util.HashedWheelTimer;
 import io.netty.util.Timer;
 import io.vertx.core.Vertx;
 import io.vertx.core.http.HttpServer;
-import io.vertx.core.http.HttpServerOptions;
 import io.vertx.core.logging.Logger;
 import io.vertx.core.logging.LoggerFactory;
-import io.vertx.ext.web.Router;
 import io.vertx.ext.web.client.WebClient;
 import io.vertx.ext.web.codec.BodyCodec;
 import io.vertx.junit5.VertxTestContext;
-
 import org.apache.cassandra.sidecar.routes.HealthCheck;
 import org.apache.cassandra.sidecar.routes.HealthService;
 
@@ -100,27 +105,59 @@
     };
 
     private Vertx vertx;
-    private Router router;
-    private HttpServer httpServer;
     private int port;
     private List<CQLSession> sessions = new LinkedList<>();
+    private Injector injector;
 
     @BeforeEach
-    void setUp() throws IOException
+    void setUp() throws InterruptedException
     {
-        vertx = Vertx.vertx();
-        router = Router.router(vertx);
-        ServerSocket socket = new ServerSocket(0);
-        port = socket.getLocalPort();
-        httpServer = vertx.createHttpServer(new HttpServerOptions()
-                                            .setPort(port)
-                                            .setLogActivity(true));
+        AtomicBoolean failedToListen = new AtomicBoolean(false);
+
+        do
+        {
+            injector = Guice.createInjector(Modules.override(new MainModule())
+                                                   .with(new IntegrationTestModule(1, sessions)));
+            vertx = injector.getInstance(Vertx.class);
+            HttpServer httpServer = injector.getInstance(HttpServer.class);
+            Configuration config = injector.getInstance(Configuration.class);
+            port = config.getPort();
+
+            CountDownLatch waitLatch = new CountDownLatch(1);
+            httpServer.listen(port, res ->
+            {
+                if (res.succeeded())
+                {
+                    logger.info("Succeeded to listen on port " + port);
+                }
+                else
+                {
+                    logger.error("Failed to listen on port " + port + " " + res.cause());
+                    failedToListen.set(true);
+                }
+                waitLatch.countDown();
+            });
+
+            if (waitLatch.await(60, TimeUnit.SECONDS))
+                logger.info("Listen complete before timeout.");
+            else
+                logger.error("Listen complete timed out.");
+
+            if (failedToListen.get())
+                closeClusters();
+        } while(failedToListen.get());
     }
 
     @AfterEach
-    void tearDown()
+    void tearDown() throws InterruptedException
     {
-        vertx.close();
+        CountDownLatch waitLatch = new CountDownLatch(1);
+        vertx.close(res -> waitLatch.countDown());
+        if (waitLatch.await(60, TimeUnit.SECONDS))
+            logger.info("Close complete before timeout.");
+        else
+            logger.error("Close timed out.");
+
     }
 
     @AfterEach
@@ -208,62 +245,44 @@
     public void testDownHostTurnsOn() throws Throwable
     {
         VertxTestContext testContext = new VertxTestContext();
-        try (Server server = Server.builder()
-                                   .withMultipleNodesPerIp(true)
-                                   .withAddressResolver(new NodePerPortResolver(new byte[]{ 127, 0, 0, 1 }, 49152))
-                                   .build())
+        BoundCluster bc = injector.getInstance(BoundCluster.class);
+        BoundNode node = bc.node(0);
+        HealthCheck check = injector.getInstance(HealthCheck.class);
+        HealthService service = injector.getInstance(HealthService.class);
+        Server server = injector.getInstance(Server.class);
+
+        try
         {
-            ClusterSpec cluster = ClusterSpec.builder()
-                                             .withNodes(1)
-                                             .build();
-            BoundCluster bCluster = server.register(cluster);
+            WebClient client = WebClient.create(vertx);
+            long start = System.currentTimeMillis();
+            client.get(port, "localhost", "/api/v1/__health")
+                  .as(BodyCodec.string())
+                  .send(testContext.succeeding(response -> testContext.verify(() ->
+                  {
+                      assertEquals(503, response.statusCode());
 
-            BoundNode node = bCluster.node(0);
-            node.stop();
-            CQLSession session = new CQLSession(node.inetSocketAddress(), shared);
-            sessions.add(session);
-            HealthCheck check = new HealthCheck(session);
-            HealthService service = new HealthService(new Configuration.Builder()
-                                                      .setHealthCheckFrequency(1000)
-                                                      .build(),
-                                                      check, session);
-            service.start();
-            try
+                      node.start();
+                      while ((System.currentTimeMillis() - start) < (1000 * 60 * 2) && !check.get())
+                          Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS);
+                      service.refreshNow();
+                      client.get(port, "localhost", "/api/v1/__health")
+                            .as(BodyCodec.string())
+                            .send(testContext.succeeding(upResponse -> testContext.verify(() ->
+                            {
+                                assertEquals(200, upResponse.statusCode());
+                                testContext.completeNow();
+                            })));
+                  })));
+            assertTrue(testContext.awaitCompletion(125, TimeUnit.SECONDS));
+            if (testContext.failed())
             {
-                router.route("/health").handler(service::handleHealth);
-                httpServer.requestHandler(router);
-                httpServer.listen();
-
-                WebClient client = WebClient.create(vertx);
-                long start = System.currentTimeMillis();
-                client.get(port, "localhost", "/health")
-                      .as(BodyCodec.string())
-                      .send(testContext.succeeding(response -> testContext.verify(() ->
-                      {
-                          assertEquals(503, response.statusCode());
-
-                          node.start();
-                          while ((System.currentTimeMillis() - start) < (1000 * 60 * 2) && !check.get())
-                              Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS);
-                          service.refreshNow();
-                          client.get(port, "localhost", "/health")
-                                .as(BodyCodec.string())
-                                .send(testContext.succeeding(upResponse -> testContext.verify(() ->
-                                {
-                                    assertEquals(200, upResponse.statusCode());
-                                    testContext.completeNow();
-                                })));
-                      })));
-                assertTrue(testContext.awaitCompletion(125, TimeUnit.SECONDS));
-                if (testContext.failed())
-                {
-                    throw testContext.causeOfFailure();
-                }
+                throw testContext.causeOfFailure();
             }
-            finally
-            {
-                service.stop();
-            }
+        }
+        finally
+        {
+            service.stop();
+            server.close();
         }
     }
 
@@ -273,4 +292,75 @@
         sessions.add(session);
         return new HealthCheck(session);
     }
+
+    private static class IntegrationTestModule extends AbstractModule
+    {
+        private final int nodeCount;
+        private final List<CQLSession> sessions;
+
+        private IntegrationTestModule(int count, List<CQLSession> sessions)
+        {
+            this.nodeCount = count;
+            this.sessions = sessions;
+        }
+
+        @Provides
+        @Singleton
+        BoundCluster cluster(Server server)
+        {
+            ClusterSpec cluster = ClusterSpec.builder()
+                                             .withNodes(nodeCount)
+                                             .build();
+            BoundCluster bc = server.register(cluster);
+            for (BoundNode n : bc.getNodes())
+                n.stop();
+
+            return bc;
+        }
+
+        @Provides
+        @Singleton
+        BoundNode node(BoundCluster bc)
+        {
+            return bc.node(0);
+        }
+
+        @Provides
+        @Singleton
+        Server server()
+        {
+            return Server.builder()
+                         .withMultipleNodesPerIp(true)
+                         .withAddressResolver(new NodePerPortResolver(new byte[]{ 127, 0, 0, 1 }, 49152))
+                         .build();
+        }
+
+        @Provides
+        @Singleton
+        HealthCheck healthCheck(BoundNode node)
+        {
+            CQLSession session = new CQLSession(node.inetSocketAddress(), shared);
+            sessions.add(session);
+            HealthCheck check = new HealthCheck(session);
+            return check;
+        }
+
+        @Provides
+        @Singleton
+        public Configuration configuration() throws IOException
+        {
+            ServerSocket socket = new ServerSocket(0);
+            int randomPort = socket.getLocalPort();
+            socket.close();
+
+            return new Configuration.Builder()
+                   .setCassandraHost("INVALID_FOR_TEST")
+                   .setCassandraPort(0)
+                   .setHost("127.0.0.1")
+                   .setPort(randomPort)
+                   .setHealthCheckFrequency(1000)
+                   .setSslEnabled(false)
+                   .build();
+        }
+    }
 }
diff --git a/src/main/java/org/apache/cassandra/sidecar/Configuration.java b/src/main/java/org/apache/cassandra/sidecar/Configuration.java
index 5fff3b8..5f289c5 100644
--- a/src/main/java/org/apache/cassandra/sidecar/Configuration.java
+++ b/src/main/java/org/apache/cassandra/sidecar/Configuration.java
@@ -43,11 +43,13 @@
     /* SSL related settings */
     @Nullable
     private final String keyStorePath;
+
     @Nullable
     private final String keyStorePassword;
 
     @Nullable
     private final String trustStorePath;
+
     @Nullable
     private final String trustStorePassword;
 
diff --git a/src/main/java/org/apache/cassandra/sidecar/MainModule.java b/src/main/java/org/apache/cassandra/sidecar/MainModule.java
index 82c9c69..4db26c5 100644
--- a/src/main/java/org/apache/cassandra/sidecar/MainModule.java
+++ b/src/main/java/org/apache/cassandra/sidecar/MainModule.java
@@ -41,6 +41,10 @@
 import io.vertx.ext.web.handler.LoggerHandler;
 import io.vertx.ext.web.handler.StaticHandler;
 import org.apache.cassandra.sidecar.routes.HealthService;
+import org.apache.cassandra.sidecar.routes.SwaggerOpenApiResource;
+import org.jboss.resteasy.plugins.server.vertx.VertxRegistry;
+import org.jboss.resteasy.plugins.server.vertx.VertxRequestHandler;
+import org.jboss.resteasy.plugins.server.vertx.VertxResteasyDeployment;
 
 /**
  * Provides main binding for more complex Guice dependencies
@@ -53,17 +57,15 @@
     @Singleton
     public Vertx getVertx()
     {
-        return Vertx.vertx(new VertxOptions().setMetricsOptions(
-                new DropwizardMetricsOptions()
-                        .setEnabled(true)
-                        .setJmxEnabled(true)
-                        .setJmxDomain("cassandra-sidecar-metrics")
-        ));
+        return Vertx.vertx(new VertxOptions().setMetricsOptions(new DropwizardMetricsOptions()
+                                                                .setEnabled(true)
+                                                                .setJmxEnabled(true)
+                                                                .setJmxDomain("cassandra-sidecar-metrics")));
     }
 
     @Provides
     @Singleton
-    public HttpServer vertxServer(Vertx vertx, Configuration conf, Router router)
+    public HttpServer vertxServer(Vertx vertx, Configuration conf, Router router, VertxRequestHandler restHandler)
     {
         HttpServerOptions options = new HttpServerOptions().setLogActivity(true);
 
@@ -82,26 +84,41 @@
             }
         }
 
-        HttpServer server = vertx.createHttpServer(options);
-        server.requestHandler(router);
-        return server;
+        router.route().pathRegex(".*").handler(rc -> restHandler.handle(rc.request()));
+
+        return vertx.createHttpServer(options)
+                    .requestHandler(router);
     }
 
     @Provides
     @Singleton
-    public Router vertxRouter(Vertx vertx, HealthService healthService)
+    private VertxRequestHandler configureServices(Vertx vertx, HealthService healthService)
+    {
+        VertxResteasyDeployment deployment = new VertxResteasyDeployment();
+        deployment.start();
+        VertxRegistry r = deployment.getRegistry();
+
+        r.addPerInstanceResource(SwaggerOpenApiResource.class);
+        r.addSingletonResource(healthService);
+
+        return new VertxRequestHandler(vertx, deployment);
+    }
+
+    @Provides
+    @Singleton
+    public Router vertxRouter(Vertx vertx)
     {
         Router router = Router.router(vertx);
         router.route().handler(LoggerHandler.create());
 
-        // include docs generated into src/main/resources/docs
-        StaticHandler swagger = StaticHandler.create()
-                                             .setWebRoot("docs")
-                                             .setCachingEnabled(false);
-        router.route().path("/docs/*").handler(swagger);
+        // Static web assets for Swagger
+        StaticHandler swaggerStatic = StaticHandler.create("META-INF/resources/webjars/swagger-ui");
+        router.route().path("/static/swagger-ui/*").handler(swaggerStatic);
 
-        // API paths
-        router.route().path("/api/v1/__health").handler(healthService::handleHealth);
+        // Docs index.html page
+        StaticHandler docs = StaticHandler.create("docs");
+        router.route().path("/docs/*").handler(docs);
+
         return router;
     }
 
diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/HealthService.java b/src/main/java/org/apache/cassandra/sidecar/routes/HealthService.java
index ddf5c54..fa877c4 100644
--- a/src/main/java/org/apache/cassandra/sidecar/routes/HealthService.java
+++ b/src/main/java/org/apache/cassandra/sidecar/routes/HealthService.java
@@ -22,8 +22,12 @@
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Supplier;
-
 import javax.annotation.Nullable;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
 
 import com.google.common.collect.ImmutableMap;
 
@@ -31,13 +35,12 @@
 import com.datastax.driver.core.Host;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import io.netty.handler.codec.http.HttpHeaderValues;
 import io.netty.handler.codec.http.HttpResponseStatus;
-import io.vertx.core.http.HttpHeaders;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
 import io.vertx.core.json.Json;
 import io.vertx.core.logging.Logger;
 import io.vertx.core.logging.LoggerFactory;
-import io.vertx.ext.web.RoutingContext;
 import org.apache.cassandra.sidecar.CQLSession;
 import org.apache.cassandra.sidecar.Configuration;
 
@@ -45,6 +48,7 @@
  * Tracks health check[s] and provides a REST response that should match that defined by api.yaml
  */
 @Singleton
+@Path("/api/v1/__health")
 public class HealthService implements Host.StateListener
 {
     private static final Logger logger = LoggerFactory.getLogger(HealthService.class);
@@ -104,21 +108,19 @@
         executor.shutdown();
     }
 
-    public void handleHealth(RoutingContext rc)
+    @Operation(summary = "Health Check for Cassandra's status",
+    description = "Returns HTTP 200 if Cassandra is available, 503 otherwise",
+    responses = {
+    @ApiResponse(responseCode = "200", description = "Cassandra is available"),
+    @ApiResponse(responseCode = "503", description = "Cassandra is not available")
+    })
+    @Produces(MediaType.APPLICATION_JSON)
+    @GET
+    public Response doGet()
     {
-        try
-        {
-            int status = lastKnownStatus ? HttpResponseStatus.OK.code() : HttpResponseStatus.SERVICE_UNAVAILABLE.code();
-            rc.response()
-              .putHeader(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_JSON)
-              .setStatusCode(status)
-              .end(Json.encode(ImmutableMap.of("status", lastKnownStatus ? "OK" : "NOT_OK")));
-        }
-        catch (Exception e)
-        {
-            logger.error("Caught exception", e);
-            rc.response().setStatusCode(400).end();
-        }
+        int status = lastKnownStatus ? HttpResponseStatus.OK.code() : HttpResponseStatus.SERVICE_UNAVAILABLE.code();
+        return Response.status(status).entity(Json.encode(ImmutableMap.of("status", lastKnownStatus ?
+                                                                                    "OK" : "NOT_OK"))).build();
     }
 
     public void onAdd(Host host)
diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/SwaggerOpenApiResource.java b/src/main/java/org/apache/cassandra/sidecar/routes/SwaggerOpenApiResource.java
new file mode 100644
index 0000000..e7c97d5
--- /dev/null
+++ b/src/main/java/org/apache/cassandra/sidecar/routes/SwaggerOpenApiResource.java
@@ -0,0 +1,60 @@
+package org.apache.cassandra.sidecar.routes;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import javax.servlet.ServletConfig;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Application;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+
+import io.swagger.v3.core.util.Json;
+import io.swagger.v3.jaxrs2.Reader;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.integration.SwaggerConfiguration;
+import io.swagger.v3.oas.models.OpenAPI;
+
+/**
+ * Exposes Swagger OpenAPI definition for all SideCar REST APIs
+ */
+@Path("/api/v1/schema/openapi.{type:json}")
+public class SwaggerOpenApiResource
+{
+    static final OpenAPI OAS;
+
+    static
+    {
+        Reader reader = new Reader(new SwaggerConfiguration());
+        OAS = reader.read(new HashSet(Arrays.asList(HealthService.class)));
+    }
+
+    @Context
+    ServletConfig config;
+
+    @Context
+    Application app;
+
+    @GET
+    @Produces({ MediaType.APPLICATION_JSON})
+    @Operation(hidden = true)
+    public Response getOpenApi(@Context HttpHeaders headers,
+                               @Context UriInfo uriInfo,
+                               @PathParam("type") String type)
+    {
+        return Response.status(Response.Status.OK)
+                       .entity(Json.pretty(OAS))
+                       .type(MediaType.APPLICATION_JSON_TYPE)
+                       .build();
+    }
+
+    public SwaggerOpenApiResource()
+    {
+        super();
+    }
+}
diff --git a/src/main/resources/docs/index.html b/src/main/resources/docs/index.html
new file mode 100644
index 0000000..c73772f
--- /dev/null
+++ b/src/main/resources/docs/index.html
@@ -0,0 +1,95 @@
+<!-- HTML for static distribution bundle build -->
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <title>Swagger UI</title>
+  <link href="https://fonts.googleapis.com/css?family=Open+Sans:400,700|Source+Code+Pro:300,600|Titillium+Web:400,600,700" rel="stylesheet">
+  <link rel="stylesheet" type="text/css" href="/static/swagger-ui/3.10.0/swagger-ui.css" >
+  <link rel="icon" type="image/png" href="/static/swagger-ui/3.10.0/favicon-32x32.png" sizes="32x32" />
+  <link rel="icon" type="image/png" href="/static/swagger-ui/3.10.0/favicon-16x16.png" sizes="16x16" />
+  <style>
+    html
+    {
+      box-sizing: border-box;
+      overflow: -moz-scrollbars-vertical;
+      overflow-y: scroll;
+    }
+    *,
+    *:before,
+    *:after
+    {
+      box-sizing: inherit;
+    }
+
+    body {
+      margin:0;
+      background: #fafafa;
+    }
+  </style>
+</head>
+
+<body>
+
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="position:absolute;width:0;height:0">
+  <defs>
+    <symbol viewBox="0 0 20 20" id="unlocked">
+          <path d="M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V6h2v-.801C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8z"></path>
+    </symbol>
+
+    <symbol viewBox="0 0 20 20" id="locked">
+      <path d="M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8zM12 8H8V5.199C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8z"/>
+    </symbol>
+
+    <symbol viewBox="0 0 20 20" id="close">
+      <path d="M14.348 14.849c-.469.469-1.229.469-1.697 0L10 11.819l-2.651 3.029c-.469.469-1.229.469-1.697 0-.469-.469-.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-.469-.469-.469-1.228 0-1.697.469-.469 1.228-.469 1.697 0L10 8.183l2.651-3.031c.469-.469 1.228-.469 1.697 0 .469.469.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c.469.469.469 1.229 0 1.698z"/>
+    </symbol>
+
+    <symbol viewBox="0 0 20 20" id="large-arrow">
+      <path d="M13.25 10L6.109 2.58c-.268-.27-.268-.707 0-.979.268-.27.701-.27.969 0l7.83 7.908c.268.271.268.709 0 .979l-7.83 7.908c-.268.271-.701.27-.969 0-.268-.269-.268-.707 0-.979L13.25 10z"/>
+    </symbol>
+
+    <symbol viewBox="0 0 20 20" id="large-arrow-down">
+      <path d="M17.418 6.109c.272-.268.709-.268.979 0s.271.701 0 .969l-7.908 7.83c-.27.268-.707.268-.979 0l-7.908-7.83c-.27-.268-.27-.701 0-.969.271-.268.709-.268.979 0L10 13.25l7.418-7.141z"/>
+    </symbol>
+
+
+    <symbol viewBox="0 0 24 24" id="jump-to">
+      <path d="M19 7v4H5.83l3.58-3.59L8 6l-6 6 6 6 1.41-1.41L5.83 13H21V7z"/>
+    </symbol>
+
+    <symbol viewBox="0 0 24 24" id="expand">
+      <path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/>
+    </symbol>
+
+  </defs>
+</svg>
+
+<div id="swagger-ui"></div>
+
+<script src="/static/swagger-ui/3.10.0/swagger-ui-bundle.js"> </script>
+<script src="/static/swagger-ui/3.10.0/swagger-ui-standalone-preset.js"> </script>
+<script>
+window.onload = function() {
+  
+  // Build a system
+  const ui = SwaggerUIBundle({
+    url: "/api/v1/schema/openapi.json",
+    dom_id: '#swagger-ui',
+    deepLinking: true,
+    presets: [
+      SwaggerUIBundle.presets.apis,
+      SwaggerUIStandalonePreset
+    ],
+    plugins: [
+      SwaggerUIBundle.plugins.DownloadUrl
+    ],
+    layout: "StandaloneLayout"
+  })
+
+  window.ui = ui
+}
+</script>
+</body>
+
+</html>
diff --git a/src/test/java/org/apache/cassandra/sidecar/AbstractHealthServiceTest.java b/src/test/java/org/apache/cassandra/sidecar/AbstractHealthServiceTest.java
index ec6a9c5..f84bb3f 100644
--- a/src/test/java/org/apache/cassandra/sidecar/AbstractHealthServiceTest.java
+++ b/src/test/java/org/apache/cassandra/sidecar/AbstractHealthServiceTest.java
@@ -18,6 +18,7 @@
 
 package org.apache.cassandra.sidecar;
 
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 
 import org.junit.Assert;
@@ -26,9 +27,13 @@
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
+import com.google.inject.util.Modules;
 import io.vertx.core.Vertx;
 import io.vertx.core.http.HttpServer;
 import io.vertx.ext.web.client.WebClient;
@@ -43,19 +48,28 @@
  */
 public abstract class AbstractHealthServiceTest
 {
+    private static final Logger logger = LoggerFactory.getLogger(AbstractHealthServiceTest.class);
     private MockHealthCheck check;
     private HealthService service;
     private Vertx vertx;
     private Configuration config;
+    private HttpServer server;
 
-    public abstract AbstractModule getTestModule();
     public abstract boolean isSslEnabled();
 
+    public AbstractModule getTestModule()
+    {
+        if (isSslEnabled())
+            return new TestSslModule();
+
+        return new TestModule();
+    }
+
     @BeforeEach
     void setUp() throws InterruptedException
     {
-        Injector injector = Guice.createInjector(getTestModule());
-        HttpServer server = injector.getInstance(HttpServer.class);
+        Injector injector = Guice.createInjector(Modules.override(new MainModule()).with(getTestModule()));
+        server = injector.getInstance(HttpServer.class);
 
         check = injector.getInstance(MockHealthCheck.class);
         service = injector.getInstance(HealthService.class);
@@ -69,9 +83,15 @@
     }
 
     @AfterEach
-    void tearDown()
+    void tearDown() throws InterruptedException
     {
+        final CountDownLatch closeLatch = new CountDownLatch(1);
+        server.close(res -> closeLatch.countDown());
         vertx.close();
+        if (closeLatch.await(60, TimeUnit.SECONDS))
+            logger.info("Close event received before timeout.");
+        else
+            logger.error("Close event timed out.");
     }
 
     @DisplayName("Should return HTTP 200 OK when check=True")
diff --git a/src/test/java/org/apache/cassandra/sidecar/HealthServiceSslTest.java b/src/test/java/org/apache/cassandra/sidecar/HealthServiceSslTest.java
index 9ceb2b8..641ba55 100644
--- a/src/test/java/org/apache/cassandra/sidecar/HealthServiceSslTest.java
+++ b/src/test/java/org/apache/cassandra/sidecar/HealthServiceSslTest.java
@@ -21,8 +21,6 @@
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.extension.ExtendWith;
 
-import com.google.inject.AbstractModule;
-import io.vertx.core.Vertx;
 import io.vertx.junit5.VertxExtension;
 
 /**
@@ -32,12 +30,6 @@
 @ExtendWith(VertxExtension.class)
 public class HealthServiceSslTest extends AbstractHealthServiceTest
 {
-
-    public AbstractModule getTestModule()
-    {
-        return new TestSslModule(Vertx.vertx());
-    }
-
     public boolean isSslEnabled()
     {
         return true;
diff --git a/src/test/java/org/apache/cassandra/sidecar/HealthServiceTest.java b/src/test/java/org/apache/cassandra/sidecar/HealthServiceTest.java
index 2528660..ef088db 100644
--- a/src/test/java/org/apache/cassandra/sidecar/HealthServiceTest.java
+++ b/src/test/java/org/apache/cassandra/sidecar/HealthServiceTest.java
@@ -21,8 +21,6 @@
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.extension.ExtendWith;
 
-import com.google.inject.AbstractModule;
-import io.vertx.core.Vertx;
 import io.vertx.junit5.VertxExtension;
 
 /**
@@ -32,12 +30,6 @@
 @ExtendWith(VertxExtension.class)
 public class HealthServiceTest extends AbstractHealthServiceTest
 {
-
-    public AbstractModule getTestModule()
-    {
-        return new TestModule(Vertx.vertx());
-    }
-
     public boolean isSslEnabled()
     {
         return false;
diff --git a/src/test/java/org/apache/cassandra/sidecar/TestModule.java b/src/test/java/org/apache/cassandra/sidecar/TestModule.java
index 102c95c..608ea8e 100644
--- a/src/test/java/org/apache/cassandra/sidecar/TestModule.java
+++ b/src/test/java/org/apache/cassandra/sidecar/TestModule.java
@@ -21,12 +21,6 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
-import io.vertx.core.Vertx;
-import io.vertx.core.http.HttpServer;
-import io.vertx.core.http.HttpServerOptions;
-import io.vertx.core.net.JksOptions;
-import io.vertx.ext.web.Router;
-import io.vertx.ext.web.handler.LoggerHandler;
 import org.apache.cassandra.sidecar.mocks.MockHealthCheck;
 import org.apache.cassandra.sidecar.routes.HealthService;
 
@@ -35,20 +29,6 @@
  */
 public class TestModule extends AbstractModule
 {
-    private Vertx vertx;
-
-    public TestModule(Vertx vertx)
-    {
-        this.vertx = vertx;
-    }
-
-    @Provides
-    @Singleton
-    public Vertx getVertx()
-    {
-        return vertx;
-    }
-
     @Provides
     @Singleton
     public HealthService healthService(Configuration config, MockHealthCheck check)
@@ -65,30 +45,6 @@
 
     @Provides
     @Singleton
-    public HttpServer vertxServer(Vertx vertx, Router router, Configuration conf)
-    {
-        HttpServerOptions options = new HttpServerOptions().setLogActivity(true);
-        options.setKeyStoreOptions(new JksOptions()
-                                       .setPath(conf.getKeyStorePath())
-                                       .setPassword(conf.getKeystorePassword()))
-                   .setSsl(conf.isSslEnabled());
-        HttpServer server = vertx.createHttpServer(options);
-        server.requestHandler(router);
-        return server;
-    }
-
-    @Provides
-    @Singleton
-    public Router vertxRouter(Vertx vertx, HealthService healthService)
-    {
-        Router router = Router.router(vertx);
-        router.route().handler(LoggerHandler.create());
-        router.route().path("/api/v1/__health").handler(healthService::handleHealth);
-        return router;
-    }
-
-    @Provides
-    @Singleton
     public Configuration configuration()
     {
         return abstractConfig();
diff --git a/src/test/java/org/apache/cassandra/sidecar/TestSslModule.java b/src/test/java/org/apache/cassandra/sidecar/TestSslModule.java
index 0e9cd9b..b140bb7 100644
--- a/src/test/java/org/apache/cassandra/sidecar/TestSslModule.java
+++ b/src/test/java/org/apache/cassandra/sidecar/TestSslModule.java
@@ -18,18 +18,11 @@
 
 package org.apache.cassandra.sidecar;
 
-import io.vertx.core.Vertx;
-
 /**
  * Changes to the TestModule to define SSL dependencies
  */
 public class TestSslModule extends TestModule
 {
-    public TestSslModule(Vertx vertx)
-    {
-        super(vertx);
-    }
-
     @Override
     public Configuration abstractConfig()
     {