CASSANDRASC-70: Added Client Methods for Obtaining Sidecar and Cassandra Health

Patch by Yuriy Semchyshyn; Reviewed by Dinesh Joshi, Francisco Guerrero, Yifan Cai for CASSANDRASC-70
diff --git a/CHANGES.txt b/CHANGES.txt
index 5453a0e..64ecc54 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,6 @@
 1.0.0
 -----
+ * Add Client Methods for Obtaining Sidecar and Cassandra Health (CASSANDRASC-70)
  * Publish bytes streamed and written metrics (CASSANDRASC-68)
  * Extract the in-jvm dtest template for use in other projects (CASSANDRASC-55)
  * Fix relocation of native libraries for vertx-client-shaded (CASSANDRASC-67)
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/RequestContext.java b/client/src/main/java/org/apache/cassandra/sidecar/client/RequestContext.java
index 0c3a16e..8d09d17 100644
--- a/client/src/main/java/org/apache/cassandra/sidecar/client/RequestContext.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/RequestContext.java
@@ -22,6 +22,7 @@
 import java.util.HashMap;
 import java.util.Map;
 
+import org.apache.cassandra.sidecar.client.request.CassandraHealthRequest;
 import org.apache.cassandra.sidecar.client.request.CleanSSTableUploadSessionRequest;
 import org.apache.cassandra.sidecar.client.request.ClearSnapshotRequest;
 import org.apache.cassandra.sidecar.client.request.CreateSnapshotRequest;
@@ -33,6 +34,7 @@
 import org.apache.cassandra.sidecar.client.request.RingRequest;
 import org.apache.cassandra.sidecar.client.request.SSTableComponentRequest;
 import org.apache.cassandra.sidecar.client.request.SchemaRequest;
+import org.apache.cassandra.sidecar.client.request.SidecarHealthRequest;
 import org.apache.cassandra.sidecar.client.request.TimeSkewRequest;
 import org.apache.cassandra.sidecar.client.request.UploadSSTableRequest;
 import org.apache.cassandra.sidecar.client.retry.ExponentialBackoffRetryPolicy;
@@ -48,6 +50,8 @@
  */
 public class RequestContext
 {
+    protected static final SidecarHealthRequest SIDECAR_HEALTH_REQUEST = new SidecarHealthRequest();
+    protected static final CassandraHealthRequest CASSANDRA_HEALTH_REQUEST = new CassandraHealthRequest();
     protected static final SchemaRequest FULL_SCHEMA_REQUEST = new SchemaRequest();
     protected static final TimeSkewRequest TIME_SKEW_REQUEST = new TimeSkewRequest();
     protected static final NodeSettingsRequest NODE_SETTINGS_REQUEST = new NodeSettingsRequest();
@@ -174,6 +178,28 @@
         }
 
         /**
+         * Sets the {@code request} to be a {@link SidecarHealthRequest}
+         * and returns a reference to this Builder enabling method chaining
+         *
+         * @return a reference to this Builder
+         */
+        public Builder sidecarHealthRequest()
+        {
+            return request(SIDECAR_HEALTH_REQUEST);
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link CassandraHealthRequest}
+         * and returns a reference to this Builder enabling method chaining
+         *
+         * @return a reference to this Builder
+         */
+        public Builder cassandraHealthRequest()
+        {
+            return request(CASSANDRA_HEALTH_REQUEST);
+        }
+
+        /**
          * Sets the {@code request} to be a {@link SchemaRequest} for the full schema and returns a reference to
          * this Builder enabling method chaining.
          *
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java
index 450dc5f..16b50c2 100644
--- a/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java
@@ -28,12 +28,14 @@
 import io.netty.handler.codec.http.HttpResponseStatus;
 import org.apache.cassandra.sidecar.client.request.ImportSSTableRequest;
 import org.apache.cassandra.sidecar.client.retry.IgnoreConflictRetryPolicy;
+import org.apache.cassandra.sidecar.client.retry.OncePerInstanceRetryPolicy;
 import org.apache.cassandra.sidecar.client.retry.RetryPolicy;
 import org.apache.cassandra.sidecar.client.retry.RunnableOnStatusCodeRetryPolicy;
 import org.apache.cassandra.sidecar.client.selection.InstanceSelectionPolicy;
 import org.apache.cassandra.sidecar.client.selection.RandomInstanceSelectionPolicy;
 import org.apache.cassandra.sidecar.common.NodeSettings;
 import org.apache.cassandra.sidecar.common.data.GossipInfoResponse;
+import org.apache.cassandra.sidecar.common.data.HealthResponse;
 import org.apache.cassandra.sidecar.common.data.ListSnapshotFilesResponse;
 import org.apache.cassandra.sidecar.common.data.RingResponse;
 import org.apache.cassandra.sidecar.common.data.SSTableImportResponse;
@@ -69,6 +71,32 @@
     }
 
     /**
+     * Executes the Sidecar health request using the configured selection policy and with no retries
+     *
+     * @return a completable future of the Sidecar health response
+     */
+    public CompletableFuture<HealthResponse> sidecarHealth()
+    {
+        return executor.executeRequestAsync(requestBuilder()
+                .sidecarHealthRequest()
+                .retryPolicy(new OncePerInstanceRetryPolicy())
+                .build());
+    }
+
+    /**
+     * Executes the Cassandra health request using the configured selection policy and with no retries
+     *
+     * @return a completable future of the Cassandra health response
+     */
+    public CompletableFuture<HealthResponse> cassandraHealth()
+    {
+        return executor.executeRequestAsync(requestBuilder()
+                .cassandraHealthRequest()
+                .retryPolicy(new OncePerInstanceRetryPolicy())
+                .build());
+    }
+
+    /**
      * Executes the full schema request using the default retry policy and configured selection policy
      *
      * @return a completable future of the full schema response
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/request/CassandraHealthRequest.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/CassandraHealthRequest.java
new file mode 100644
index 0000000..2061c89
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/CassandraHealthRequest.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import io.netty.handler.codec.http.HttpMethod;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.data.HealthResponse;
+
+/**
+ * Represents a request to retrieve the Cassandra health
+ */
+public class CassandraHealthRequest extends DecodableRequest<HealthResponse>
+{
+    /**
+     * Constructs a request to retrieve the Cassandra health
+     */
+    public CassandraHealthRequest()
+    {
+        super(ApiEndpointsV1.CASSANDRA_HEALTH_ROUTE);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public HttpMethod method()
+    {
+        return HttpMethod.GET;
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/request/SidecarHealthRequest.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/SidecarHealthRequest.java
new file mode 100644
index 0000000..49aa139
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/SidecarHealthRequest.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import io.netty.handler.codec.http.HttpMethod;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.data.HealthResponse;
+
+/**
+ * Represents a request to retrieve the Sidecar health
+ */
+public class SidecarHealthRequest extends DecodableRequest<HealthResponse>
+{
+    /**
+     * Constructs a request to retrieve the Sidecar health
+     */
+    public SidecarHealthRequest()
+    {
+        super(ApiEndpointsV1.HEALTH_ROUTE);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public HttpMethod method()
+    {
+        return HttpMethod.GET;
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/retry/OncePerInstanceRetryPolicy.java b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/OncePerInstanceRetryPolicy.java
new file mode 100644
index 0000000..cb14429
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/OncePerInstanceRetryPolicy.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.retry;
+
+import java.util.concurrent.CompletableFuture;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import org.apache.cassandra.sidecar.client.HttpResponse;
+import org.apache.cassandra.sidecar.client.exception.RetriesExhaustedException;
+import org.apache.cassandra.sidecar.client.request.Request;
+
+/**
+ * A retry policy that attempts to execute the request once on each instance
+ * until the first successful response is received, or fails if none were successful
+ */
+public class OncePerInstanceRetryPolicy extends RetryPolicy
+{
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void onResponse(CompletableFuture<HttpResponse> responseFuture,
+                           Request request,
+                           HttpResponse response,
+                           Throwable throwable,
+                           int attempts,
+                           boolean canRetryOnADifferentHost,
+                           RetryAction retryAction)
+    {
+        if (response != null && response.statusCode() == HttpResponseStatus.OK.code())
+        {
+            responseFuture.complete(response);
+        }
+        else if (canRetryOnADifferentHost)
+        {
+            retryAction.retry(attempts + 1, 0L);
+        }
+        else
+        {
+            responseFuture.completeExceptionally(RetriesExhaustedException.of(attempts, request, response, throwable));
+        }
+    }
+}
diff --git a/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/SidecarClientTest.java b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/SidecarClientTest.java
index 77bc69a..68eb1b3 100644
--- a/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/SidecarClientTest.java
+++ b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/SidecarClientTest.java
@@ -58,6 +58,7 @@
 import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
 import org.apache.cassandra.sidecar.common.NodeSettings;
 import org.apache.cassandra.sidecar.common.data.GossipInfoResponse;
+import org.apache.cassandra.sidecar.common.data.HealthResponse;
 import org.apache.cassandra.sidecar.common.data.ListSnapshotFilesResponse;
 import org.apache.cassandra.sidecar.common.data.RingEntry;
 import org.apache.cassandra.sidecar.common.data.RingResponse;
@@ -73,6 +74,7 @@
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.assertj.core.api.Assertions.fail;
 
 abstract class SidecarClientTest
@@ -109,6 +111,72 @@
     }
 
     @Test
+    void testSidecarHealthOk() throws Exception
+    {
+        MockResponse response = new MockResponse()
+                .setResponseCode(200)
+                .setHeader("content-type", "application/json")
+                .setBody("{\"status\":\"OK\"}");
+        enqueue(response);
+
+        HealthResponse result = client.sidecarHealth().get(30, TimeUnit.SECONDS);
+        assertThat(result).isNotNull();
+        assertThat(result.status()).isEqualToIgnoringCase("OK");
+        assertThat(result.isOk()).isTrue();
+
+        validateResponseServed(ApiEndpointsV1.HEALTH_ROUTE);
+    }
+
+    @Test
+    void testSidecarHealthNotOk() throws Exception
+    {
+        MockResponse response = new MockResponse()
+                .setResponseCode(503)
+                .setHeader("content-type", "application/json")
+                .setBody("{\"status\":\"NOT_OK\"}");
+        enqueue(response);
+
+        assertThatThrownBy(() -> client.sidecarHealth().get(30, TimeUnit.SECONDS))
+                .isInstanceOf(ExecutionException.class)
+                .hasCauseInstanceOf(RetriesExhaustedException.class);
+
+        validateResponseServed(ApiEndpointsV1.HEALTH_ROUTE);
+    }
+
+    @Test
+    void testCassandraHealthOk() throws Exception
+    {
+        MockResponse response = new MockResponse()
+                .setResponseCode(200)
+                .setHeader("content-type", "application/json")
+                .setBody("{\"status\":\"OK\"}");
+        enqueue(response);
+
+        HealthResponse result = client.cassandraHealth().get(30, TimeUnit.SECONDS);
+        assertThat(result).isNotNull();
+        assertThat(result.status()).isEqualToIgnoringCase("OK");
+        assertThat(result.isOk()).isTrue();
+
+        validateResponseServed(ApiEndpointsV1.CASSANDRA_HEALTH_ROUTE);
+    }
+
+    @Test
+    void testCassandraHealthNotOk() throws Exception
+    {
+        MockResponse response = new MockResponse()
+                .setResponseCode(503)
+                .setHeader("content-type", "application/json")
+                .setBody("{\"status\":\"NOT_OK\"}");
+        enqueue(response);
+
+        assertThatThrownBy(() -> client.cassandraHealth().get(30, TimeUnit.SECONDS))
+                .isInstanceOf(ExecutionException.class)
+                .hasCauseInstanceOf(RetriesExhaustedException.class);
+
+        validateResponseServed(ApiEndpointsV1.CASSANDRA_HEALTH_ROUTE);
+    }
+
+    @Test
     void testFullSchema() throws Exception
     {
         String fullSchemaAsString = "{\"schema\":\"CREATE KEYSPACE sample_ks.sample_table ...\"}";
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/HealthResponse.java b/common/src/main/java/org/apache/cassandra/sidecar/common/data/HealthResponse.java
new file mode 100644
index 0000000..0869671
--- /dev/null
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/data/HealthResponse.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.common.data;
+
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * A class representing a response for a health request
+ * (either {@code SidecarHealthRequest} or {@code CassandraHealthRequest})
+ */
+public class HealthResponse
+{
+    @NotNull
+    private final String status;
+
+    /**
+     * Constructs a {@link HealthResponse} object with the given {@code status}
+     *
+     * @param status the status
+     */
+    public HealthResponse(@NotNull @JsonProperty("status") String status)
+    {
+        this.status = Objects.requireNonNull(status, "status must not be null").toUpperCase();
+    }
+
+    /**
+     * @return the status
+     */
+    @JsonProperty("status")
+    public String status()
+    {
+        return status;
+    }
+
+    /**
+     * @return whether the status is OK
+     */
+    @JsonIgnore
+    public boolean isOk()
+    {
+        return status.equals("OK");
+    }
+}