You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cassandra.apache.org by yc...@apache.org on 2022/10/17 23:27:10 UTC

[cassandra-sidecar] branch trunk updated: CASSANDRASC-43 Add Schema API

This is an automated email from the ASF dual-hosted git repository.

ycai pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra-sidecar.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 6e358ac  CASSANDRASC-43 Add Schema API
6e358ac is described below

commit 6e358acfce071cad16ac88c15dc2229bbb8a7944
Author: Francisco Guerrero <fr...@apple.com>
AuthorDate: Fri Oct 7 16:39:12 2022 -0700

    CASSANDRASC-43 Add Schema API
    
    This commit introduces the Schema API. Two new endpoints are added:
    
    - /api/v1/schema/keyspaces
      This endpoint returns the SchemaResponse with the full schema for all keyspaces.
    
    - /api/v1/schema/keyspaces/:keyspace
      This endpoint returns the SchemaResponse with the full schema for the requested keyspace.
    
    patch by Francisco Guerrero; reviewed by Yifan Cai, Dinesh Joshi for CASSANDRASC-43
---
 build.gradle                                       |   1 +
 checkstyle.xml                                     |   6 +-
 .../common/data/ListSnapshotFilesRequest.java      |   6 +-
 .../sidecar/common/data/QualifiedTableName.java    |   4 +-
 .../sidecar/common/data/SSTableComponent.java      |   6 +-
 .../{SSTableComponent.java => SchemaRequest.java}  |  37 ++--
 .../sidecar/common/data/SchemaResponse.java        |  75 ++++++++
 .../common/data/StreamSSTableComponentRequest.java |   8 +-
 .../common/data/ListSnapshotFilesRequestTest.java  |   6 +-
 .../data/StreamSSTableComponentRequestTest.java    |   8 +-
 .../org/apache/cassandra/sidecar/MainModule.java   |  10 +
 .../org/apache/cassandra/sidecar/models/Range.java |   6 +-
 .../sidecar/routes/ListSnapshotFilesHandler.java   |   4 +-
 .../cassandra/sidecar/routes/SchemaHandler.java    | 159 ++++++++++++++++
 .../sidecar/snapshots/SnapshotPathBuilder.java     |  16 +-
 .../cassandra/sidecar/utils/FileStreamer.java      |   2 +-
 .../sidecar/routes/SchemaHandlerTest.java          | 201 +++++++++++++++++++++
 .../routes/StreamSSTableComponentHandlerTest.java  |   4 +-
 src/test/resources/schema/test_keyspace_schema.cql |  63 +++++++
 19 files changed, 562 insertions(+), 60 deletions(-)

diff --git a/build.gradle b/build.gradle
index 87cc251..baa6168 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,6 +113,7 @@ dependencies {
     implementation('io.swagger.core.v3:swagger-jaxrs2:2.1.0')
     implementation("org.jboss.resteasy:resteasy-vertx:4.7.4.Final")
     implementation(group: 'org.jboss.spec.javax.servlet', name: 'jboss-servlet-api_4.0_spec', version: '2.0.0.Final')
+    implementation('org.jetbrains:annotations:23.0.0')
 
     // Trying to be exactly compatible with Cassandra's deps
     implementation('org.slf4j:slf4j-api:1.7.25')
diff --git a/checkstyle.xml b/checkstyle.xml
index 6206060..4108268 100644
--- a/checkstyle.xml
+++ b/checkstyle.xml
@@ -260,6 +260,10 @@ page at http://checkstyle.sourceforge.net/config.html -->
             <!-- Checks for placement of the left curly brace ('{'). -->
             <property name="option" value="nl"/>
             <property name="severity" value="error" />
+            <property name="tokens" value="ANNOTATION_DEF, CLASS_DEF, CTOR_DEF, ENUM_CONSTANT_DEF,
+             ENUM_DEF, INTERFACE_DEF, LITERAL_CATCH, LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY,
+             LITERAL_FOR, LITERAL_IF, LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_WHILE,
+             METHOD_DEF, OBJBLOCK, STATIC_INIT"/>
         </module>
 
         <module name="RightCurly">
@@ -390,4 +394,4 @@ page at http://checkstyle.sourceforge.net/config.html -->
         <property name="messageFormat" value="$1" />
     </module>
 
-</module>
\ No newline at end of file
+</module>
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequest.java b/common/src/main/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequest.java
index 887ad26..ec121ca 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequest.java
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequest.java
@@ -47,7 +47,7 @@ public class ListSnapshotFilesRequest extends QualifiedTableName
     /**
      * @return the name of the snapshot
      */
-    public String getSnapshotName()
+    public String snapshotName()
     {
         return snapshotName;
     }
@@ -66,8 +66,8 @@ public class ListSnapshotFilesRequest extends QualifiedTableName
     public String toString()
     {
         return "ListSnapshotFilesRequest{" +
-               "keyspace='" + getKeyspace() + '\'' +
-               ", tableName='" + getTableName() + '\'' +
+               "keyspace='" + keyspace() + '\'' +
+               ", tableName='" + tableName() + '\'' +
                ", snapshotName='" + snapshotName + '\'' +
                ", includeSecondaryIndexFiles=" + includeSecondaryIndexFiles +
                '}';
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/QualifiedTableName.java b/common/src/main/java/org/apache/cassandra/sidecar/common/data/QualifiedTableName.java
index 9bbcb39..78d3a79 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/QualifiedTableName.java
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/data/QualifiedTableName.java
@@ -59,7 +59,7 @@ public class QualifiedTableName
     /**
      * @return the keyspace in Cassandra
      */
-    public String getKeyspace()
+    public String keyspace()
     {
         return keyspace;
     }
@@ -67,7 +67,7 @@ public class QualifiedTableName
     /**
      * @return the table name in Cassandra
      */
-    public String getTableName()
+    public String tableName()
     {
         return tableName;
     }
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SSTableComponent.java b/common/src/main/java/org/apache/cassandra/sidecar/common/data/SSTableComponent.java
index 1752d2c..9bb039b 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SSTableComponent.java
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/data/SSTableComponent.java
@@ -41,7 +41,7 @@ public class SSTableComponent extends QualifiedTableName
     /**
      * @return the name of the SSTable component
      */
-    public String getComponentName()
+    public String componentName()
     {
         return componentName;
     }
@@ -53,8 +53,8 @@ public class SSTableComponent extends QualifiedTableName
     public String toString()
     {
         return "SSTableComponent{" +
-               "keyspace='" + getKeyspace() + '\'' +
-               ", tableName='" + getTableName() + '\'' +
+               "keyspace='" + keyspace() + '\'' +
+               ", tableName='" + tableName() + '\'' +
                ", componentName='" + componentName + '\'' +
                '}';
     }
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SSTableComponent.java b/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
similarity index 50%
copy from common/src/main/java/org/apache/cassandra/sidecar/common/data/SSTableComponent.java
copy to common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
index 1752d2c..1f136b9 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SSTableComponent.java
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
@@ -15,47 +15,34 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 package org.apache.cassandra.sidecar.common.data;
 
+import org.jetbrains.annotations.Nullable;
+
 /**
- * Represents an SSTable component that includes a keyspace, table name and component name
+ * Holder class for the {@link org.apache.cassandra.sidecar.routes.SchemaHandler}
+ * request parameters
  */
-public class SSTableComponent extends QualifiedTableName
+public class SchemaRequest extends QualifiedTableName
 {
-    private final String componentName;
-
     /**
-     * Constructor for the holder class
+     * Constructs a {@link SchemaRequest} with the {@link org.jetbrains.annotations.Nullable} {@code keyspace}.
      *
-     * @param keyspace      the keyspace in Cassandra
-     * @param tableName     the table name in Cassandra
-     * @param componentName the name of the SSTable component
-     */
-    public SSTableComponent(String keyspace, String tableName, String componentName)
-    {
-        super(keyspace, tableName);
-        this.componentName = validator.validateComponentName(componentName);
-    }
-
-    /**
-     * @return the name of the SSTable component
+     * @param keyspace the keyspace in Cassandra
      */
-    public String getComponentName()
+    public SchemaRequest(@Nullable String keyspace)
     {
-        return componentName;
+        super(keyspace, null, false);
     }
 
     /**
      * {@inheritDoc}
      */
-    @Override
     public String toString()
     {
-        return "SSTableComponent{" +
-               "keyspace='" + getKeyspace() + '\'' +
-               ", tableName='" + getTableName() + '\'' +
-               ", componentName='" + componentName + '\'' +
+        return "SchemaRequest{" +
+               "keyspace='" + keyspace() + '\'' +
+               ", tableName='" + tableName() + '\'' +
                '}';
     }
 }
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaResponse.java b/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaResponse.java
new file mode 100644
index 0000000..a9efb2b
--- /dev/null
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaResponse.java
@@ -0,0 +1,75 @@
+/*
+ * 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.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * A class representing a response for the {@link SchemaRequest}.
+ */
+public class SchemaResponse
+{
+    private final String keyspace;
+    private final String schema;
+
+    /**
+     * Constructs a {@link SchemaResponse} object with the given {@code schema}.
+     *
+     * @param schema the schema for all keyspaces
+     */
+    public SchemaResponse(String schema)
+    {
+        this.keyspace = null;
+        this.schema = Objects.requireNonNull(schema, "schema must be non-null");
+    }
+
+    /**
+     * Constructs a {@link SchemaResponse} object with the {@code schema} for the given {@code keyspace}.
+     *
+     * @param keyspace the keyspace in Cassandra
+     * @param schema   the schema for the given {@code keyspace}
+     */
+    public SchemaResponse(@JsonProperty("keyspace") String keyspace,
+                          @JsonProperty("schema") String schema)
+    {
+        this.keyspace = Objects.requireNonNull(keyspace, "keyspace must be non-null");
+        this.schema = Objects.requireNonNull(schema, "schema must be non-null");
+    }
+
+    /**
+     * @return the name of the Cassandra keyspace
+     */
+    @JsonProperty("keyspace")
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public String keyspace()
+    {
+        return keyspace;
+    }
+
+    /**
+     * @return the string representing the schema for the response
+     */
+    @JsonProperty("schema")
+    public String schema()
+    {
+        return schema;
+    }
+}
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequest.java b/common/src/main/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequest.java
index b72c9f2..0d2c61c 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequest.java
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequest.java
@@ -43,7 +43,7 @@ public class StreamSSTableComponentRequest extends SSTableComponent
     /**
      * @return the name of the snapshot
      */
-    public String getSnapshotName()
+    public String snapshotName()
     {
         return snapshotName;
     }
@@ -54,10 +54,10 @@ public class StreamSSTableComponentRequest extends SSTableComponent
     public String toString()
     {
         return "StreamSSTableComponentRequest{" +
-               "keyspace='" + getKeyspace() + '\'' +
-               ", tableName='" + getTableName() + '\'' +
+               "keyspace='" + keyspace() + '\'' +
+               ", tableName='" + tableName() + '\'' +
                ", snapshot='" + snapshotName + '\'' +
-               ", componentName='" + getComponentName() + '\'' +
+               ", componentName='" + componentName() + '\'' +
                '}';
     }
 }
diff --git a/common/src/test/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequestTest.java b/common/src/test/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequestTest.java
index f00f070..8bc7570 100644
--- a/common/src/test/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequestTest.java
+++ b/common/src/test/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequestTest.java
@@ -142,9 +142,9 @@ class ListSnapshotFilesRequestTest
     {
         ListSnapshotFilesRequest request = new ListSnapshotFilesRequest("ks", "table", "snapshot", false);
 
-        assertThat(request.getKeyspace()).isEqualTo("ks");
-        assertThat(request.getTableName()).isEqualTo("table");
-        assertThat(request.getSnapshotName()).isEqualTo("snapshot");
+        assertThat(request.keyspace()).isEqualTo("ks");
+        assertThat(request.tableName()).isEqualTo("table");
+        assertThat(request.snapshotName()).isEqualTo("snapshot");
         assertThat(request.includeSecondaryIndexFiles()).isFalse();
         assertThat(request.toString()).isEqualTo("ListSnapshotFilesRequest{keyspace='ks', tableName='table', " +
                                                  "snapshotName='snapshot', includeSecondaryIndexFiles=false}");
diff --git a/common/src/test/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequestTest.java b/common/src/test/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequestTest.java
index 5346abb..ee500dd 100644
--- a/common/src/test/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequestTest.java
+++ b/common/src/test/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequestTest.java
@@ -164,10 +164,10 @@ class StreamSSTableComponentRequestTest
         StreamSSTableComponentRequest req =
         new StreamSSTableComponentRequest("ks", "table", "snapshot", "data.db");
 
-        assertThat(req.getKeyspace()).isEqualTo("ks");
-        assertThat(req.getTableName()).isEqualTo("table");
-        assertThat(req.getSnapshotName()).isEqualTo("snapshot");
-        assertThat(req.getComponentName()).isEqualTo("data.db");
+        assertThat(req.keyspace()).isEqualTo("ks");
+        assertThat(req.tableName()).isEqualTo("table");
+        assertThat(req.snapshotName()).isEqualTo("snapshot");
+        assertThat(req.componentName()).isEqualTo("data.db");
         assertThat(req.toString()).isEqualTo("StreamSSTableComponentRequest{keyspace='ks', tableName='table', " +
                                              "snapshot='snapshot', componentName='data.db'}");
     }
diff --git a/src/main/java/org/apache/cassandra/sidecar/MainModule.java b/src/main/java/org/apache/cassandra/sidecar/MainModule.java
index a121457..3ef46b3 100644
--- a/src/main/java/org/apache/cassandra/sidecar/MainModule.java
+++ b/src/main/java/org/apache/cassandra/sidecar/MainModule.java
@@ -65,6 +65,7 @@ import org.apache.cassandra.sidecar.routes.CassandraHealthService;
 import org.apache.cassandra.sidecar.routes.FileStreamHandler;
 import org.apache.cassandra.sidecar.routes.HealthService;
 import org.apache.cassandra.sidecar.routes.ListSnapshotFilesHandler;
+import org.apache.cassandra.sidecar.routes.SchemaHandler;
 import org.apache.cassandra.sidecar.routes.StreamSSTableComponentHandler;
 import org.apache.cassandra.sidecar.routes.SwaggerOpenApiResource;
 import org.jboss.resteasy.plugins.server.vertx.VertxRegistry;
@@ -168,6 +169,7 @@ public class MainModule extends AbstractModule
                               StreamSSTableComponentHandler streamSSTableComponentHandler,
                               FileStreamHandler fileStreamHandler,
                               ListSnapshotFilesHandler listSnapshotFilesHandler,
+                              SchemaHandler schemaHandler,
                               LoggerHandler loggerHandler,
                               ErrorHandler errorHandler)
     {
@@ -194,6 +196,14 @@ public class MainModule extends AbstractModule
         router.get(API_V1_VERSION + listSnapshotFilesRoute)
               .handler(listSnapshotFilesHandler);
 
+        final String allKeyspacesSchemasRoute = "/schema/keyspaces";
+        router.get(API_V1_VERSION + allKeyspacesSchemasRoute)
+              .handler(schemaHandler);
+
+        final String keyspaceSchemaRoute = "/schema/keyspaces/:keyspace";
+        router.get(API_V1_VERSION + keyspaceSchemaRoute)
+              .handler(schemaHandler);
+
         return router;
     }
 
diff --git a/src/main/java/org/apache/cassandra/sidecar/models/Range.java b/src/main/java/org/apache/cassandra/sidecar/models/Range.java
index e224a9c..2def7fc 100644
--- a/src/main/java/org/apache/cassandra/sidecar/models/Range.java
+++ b/src/main/java/org/apache/cassandra/sidecar/models/Range.java
@@ -21,11 +21,11 @@ package org.apache.cassandra.sidecar.models;
 import java.util.Objects;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-import javax.validation.constraints.NotNull;
 
 import com.google.common.base.Preconditions;
 
 import org.apache.cassandra.sidecar.exceptions.RangeException;
+import org.jetbrains.annotations.NotNull;
 
 /**
  * Accepted Range formats are start-end, start-, -suffix_length
@@ -116,8 +116,8 @@ public class Range
     private static IllegalArgumentException invalidRangeHeaderException(String rangeHeader)
     {
         return new IllegalArgumentException("Invalid range header: " + rangeHeader + ". " +
-                                            "Supported Range formats are bytes=<start>-<end>, bytes=<start>-, " +
-                                            "bytes=-<suffix-length>");
+                                            "Supported Range formats are bytes=<start>-<end>, " +
+                                            "bytes=<start>-, bytes=-<suffix-length>");
     }
 
     // An initialized range is always valid; invalid params fail range initialization.
diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/ListSnapshotFilesHandler.java b/src/main/java/org/apache/cassandra/sidecar/routes/ListSnapshotFilesHandler.java
index 5fe8502..dd89042 100644
--- a/src/main/java/org/apache/cassandra/sidecar/routes/ListSnapshotFilesHandler.java
+++ b/src/main/java/org/apache/cassandra/sidecar/routes/ListSnapshotFilesHandler.java
@@ -27,6 +27,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import io.netty.handler.codec.http.HttpResponseStatus;
 import io.vertx.core.http.HttpServerRequest;
 import io.vertx.core.net.SocketAddress;
@@ -51,6 +52,7 @@ import org.apache.cassandra.sidecar.snapshots.SnapshotPathBuilder;
  * lists all SSTable component files including secondary index files for the "testSnapshot" snapshot for the "ks"
  * keyspace and the "tbl" table
  */
+@Singleton
 public class ListSnapshotFilesHandler extends AbstractHandler
 {
     private static final Logger logger = LoggerFactory.getLogger(ListSnapshotFilesHandler.class);
@@ -85,7 +87,7 @@ public class ListSnapshotFilesHandler extends AbstractHandler
                                  {
                                      if (fileList.isEmpty())
                                      {
-                                         String payload = "Snapshot '" + requestParams.getSnapshotName() +
+                                         String payload = "Snapshot '" + requestParams.snapshotName() +
                                                           "' not found";
                                          context.fail(new HttpException(HttpResponseStatus.NOT_FOUND.code(), payload));
                                      }
diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/SchemaHandler.java b/src/main/java/org/apache/cassandra/sidecar/routes/SchemaHandler.java
new file mode 100644
index 0000000..0a3fe3f
--- /dev/null
+++ b/src/main/java/org/apache/cassandra/sidecar/routes/SchemaHandler.java
@@ -0,0 +1,159 @@
+/*
+ * 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.routes;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.datastax.driver.core.KeyspaceMetadata;
+import com.datastax.driver.core.Metadata;
+import com.datastax.driver.core.Session;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.vertx.core.Future;
+import io.vertx.core.Vertx;
+import io.vertx.core.http.HttpServerRequest;
+import io.vertx.core.net.SocketAddress;
+import io.vertx.ext.web.RoutingContext;
+import io.vertx.ext.web.handler.HttpException;
+import org.apache.cassandra.sidecar.cluster.InstancesConfig;
+import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
+import org.apache.cassandra.sidecar.common.data.SchemaRequest;
+import org.apache.cassandra.sidecar.common.data.SchemaResponse;
+
+
+/**
+ * The {@link SchemaHandler} class returns a
+ */
+@Singleton
+public class SchemaHandler extends AbstractHandler
+{
+    private static final Logger LOGGER = LoggerFactory.getLogger(SchemaHandler.class);
+
+    /**
+     * Constructs a handler with the provided {@code instancesConfig}
+     *
+     * @param instancesConfig the instances configuration
+     */
+    @Inject
+    protected SchemaHandler(InstancesConfig instancesConfig)
+    {
+        super(instancesConfig);
+    }
+
+    @Override
+    public void handle(RoutingContext context)
+    {
+        final HttpServerRequest request = context.request();
+        final String host = getHost(context);
+        final SocketAddress remoteAddress = request.remoteAddress();
+        final SchemaRequest requestParams = extractParamsOrThrow(context);
+        final InstanceMetadata instanceMeta = instancesConfig.instanceFromHost(host);
+        LOGGER.debug("SchemaHandler received request: {} from: {}. Instance: {}",
+                     requestParams, remoteAddress, host);
+
+        final Vertx vertx = context.vertx();
+        getMetadata(vertx, instanceMeta).onFailure(throwable -> handleFailure(context, requestParams, throwable))
+                                        .onSuccess(metadata -> handleWithMetadata(context, requestParams, metadata));
+    }
+
+    /**
+     * Handles the request with the Cassandra {@link Metadata metadata}.
+     *
+     * @param context       the event to handle
+     * @param requestParams the {@link SchemaRequest} parsed from the request
+     * @param metadata      the metadata on the connected cluster, including known nodes and schema definitions
+     */
+    private void handleWithMetadata(RoutingContext context, SchemaRequest requestParams, Metadata metadata)
+    {
+        if (metadata == null)
+        {
+            // set request as failed and return
+            LOGGER.error("Failed to obtain metadata on the connected cluster for request '{}'", requestParams);
+            context.fail(HttpResponseStatus.INTERNAL_SERVER_ERROR.code());
+            return;
+        }
+
+        if (requestParams.keyspace() == null)
+        {
+            SchemaResponse schemaResponse = new SchemaResponse(metadata.exportSchemaAsString());
+            context.json(schemaResponse);
+            return;
+        }
+
+        // retrieve keyspace metadata
+        KeyspaceMetadata ksMetadata = metadata.getKeyspace(requestParams.keyspace());
+        if (ksMetadata == null)
+        {
+            // set request as failed and return
+            // keyspace does not exist
+            String errorMessage = String.format("Keyspace '%s' does not exist.",
+                                                requestParams.keyspace());
+            context.fail(new HttpException(HttpResponseStatus.NOT_FOUND.code(), errorMessage));
+            return;
+        }
+
+        SchemaResponse schemaResponse = new SchemaResponse(requestParams.keyspace(), ksMetadata.exportAsString());
+        context.json(schemaResponse);
+    }
+
+    private void handleFailure(RoutingContext context, SchemaRequest requestParams, Throwable throwable)
+    {
+        LOGGER.error("Failed to obtain keyspace metadata for request '{}'", requestParams, throwable);
+        context.fail(new HttpException(HttpResponseStatus.SERVICE_UNAVAILABLE.code(),
+                                       "Unable to reach the Cassandra service", throwable));
+    }
+
+    /**
+     * Gets cluster metadata asynchronously.
+     *
+     * @param vertx            the vertx instance
+     * @param instanceMetadata the instance metadata
+     * @return {@link Future} containing {@link Metadata}
+     */
+    private Future<Metadata> getMetadata(Vertx vertx, InstanceMetadata instanceMetadata)
+    {
+        return vertx.executeBlocking(promise -> {
+            // session() or getLocalCql() can potentially block, so move them inside a executeBlocking lambda
+            Session session = instanceMetadata.session().getLocalCql();
+            if (session == null)
+            {
+                LOGGER.error("Unable to obtain session for instance='{}' host='{}' port='{}'",
+                             instanceMetadata.id(), instanceMetadata.host(), instanceMetadata.port());
+                promise.fail(new RuntimeException(String.format("Could not obtain session for instance '%d'",
+                                                                instanceMetadata.id())));
+            }
+            else
+            {
+                promise.complete(session.getCluster().getMetadata());
+            }
+        });
+    }
+
+    /**
+     * Parses the request parameters
+     *
+     * @param rc the event to handle
+     * @return the {@link SchemaRequest} parsed from the request
+     */
+    private SchemaRequest extractParamsOrThrow(final RoutingContext rc)
+    {
+        return new SchemaRequest(rc.pathParam("keyspace"));
+    }
+}
diff --git a/src/main/java/org/apache/cassandra/sidecar/snapshots/SnapshotPathBuilder.java b/src/main/java/org/apache/cassandra/sidecar/snapshots/SnapshotPathBuilder.java
index 528a1a3..de9538d 100644
--- a/src/main/java/org/apache/cassandra/sidecar/snapshots/SnapshotPathBuilder.java
+++ b/src/main/java/org/apache/cassandra/sidecar/snapshots/SnapshotPathBuilder.java
@@ -98,10 +98,10 @@ public class SnapshotPathBuilder
         validate(request);
         // Search for the file
         return getDataDirectories(host)
-               .compose(dataDirs -> findKeyspaceDirectory(dataDirs, request.getKeyspace()))
-               .compose(keyspaceDirectory -> findTableDirectory(keyspaceDirectory, request.getTableName()))
-               .compose(tableDirectory -> findComponent(tableDirectory, request.getSnapshotName(),
-                                                        request.getComponentName()));
+               .compose(dataDirs -> findKeyspaceDirectory(dataDirs, request.keyspace()))
+               .compose(keyspaceDirectory -> findTableDirectory(keyspaceDirectory, request.tableName()))
+               .compose(tableDirectory -> findComponent(tableDirectory, request.snapshotName(),
+                                                        request.componentName()));
     }
 
     /**
@@ -116,9 +116,9 @@ public class SnapshotPathBuilder
     public Future<String> build(String host, ListSnapshotFilesRequest request)
     {
         return getDataDirectories(host)
-               .compose(dataDirs -> findKeyspaceDirectory(dataDirs, request.getKeyspace()))
-               .compose(keyspaceDirectory -> findTableDirectory(keyspaceDirectory, request.getTableName()))
-               .compose(tableDirectory -> findSnapshotDirectory(tableDirectory, request.getSnapshotName()));
+               .compose(dataDirs -> findKeyspaceDirectory(dataDirs, request.keyspace()))
+               .compose(keyspaceDirectory -> findTableDirectory(keyspaceDirectory, request.tableName()))
+               .compose(tableDirectory -> findSnapshotDirectory(tableDirectory, request.snapshotName()));
     }
 
     /**
@@ -294,7 +294,7 @@ public class SnapshotPathBuilder
     protected void validate(StreamSSTableComponentRequest request)
     {
         // Only allow .db and TOC.txt components here
-        validator.validateRestrictedComponentName(request.getComponentName());
+        validator.validateRestrictedComponentName(request.componentName());
     }
 
     /**
diff --git a/src/main/java/org/apache/cassandra/sidecar/utils/FileStreamer.java b/src/main/java/org/apache/cassandra/sidecar/utils/FileStreamer.java
index 53de99f..7e6e505 100644
--- a/src/main/java/org/apache/cassandra/sidecar/utils/FileStreamer.java
+++ b/src/main/java/org/apache/cassandra/sidecar/utils/FileStreamer.java
@@ -112,7 +112,7 @@ public class FileStreamer
         if (!isRateLimited() || acquire(response, filename, fileLength, range, startTime, promise))
         {
             // Stream data if rate limiting is disabled or if we acquire
-            LOGGER.info("Streaming range {} for file {} to client {}. Instance: {}", range, filename,
+            LOGGER.debug("Streaming range {} for file {} to client {}. Instance: {}", range, filename,
                         response.remoteAddress(), response.host());
             response.sendFile(filename, fileLength, range)
                     .onSuccess(v ->
diff --git a/src/test/java/org/apache/cassandra/sidecar/routes/SchemaHandlerTest.java b/src/test/java/org/apache/cassandra/sidecar/routes/SchemaHandlerTest.java
new file mode 100644
index 0000000..9b37781
--- /dev/null
+++ b/src/test/java/org/apache/cassandra/sidecar/routes/SchemaHandlerTest.java
@@ -0,0 +1,201 @@
+/*
+ * 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.routes;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.datastax.driver.core.Cluster;
+import com.datastax.driver.core.KeyspaceMetadata;
+import com.datastax.driver.core.Metadata;
+import com.datastax.driver.core.Session;
+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.vertx.core.Vertx;
+import io.vertx.core.http.HttpServer;
+import io.vertx.core.json.JsonObject;
+import io.vertx.ext.web.client.WebClient;
+import io.vertx.ext.web.client.predicate.ResponsePredicate;
+import io.vertx.junit5.VertxExtension;
+import io.vertx.junit5.VertxTestContext;
+import org.apache.cassandra.sidecar.Configuration;
+import org.apache.cassandra.sidecar.MainModule;
+import org.apache.cassandra.sidecar.TestModule;
+import org.apache.cassandra.sidecar.cluster.InstancesConfig;
+import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
+import org.apache.cassandra.sidecar.common.CQLSession;
+import org.apache.cassandra.sidecar.common.CassandraAdapterDelegate;
+
+import static io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND;
+import static io.netty.handler.codec.http.HttpResponseStatus.OK;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for the {@link SchemaHandler}
+ */
+@ExtendWith(VertxExtension.class)
+class SchemaHandlerTest
+{
+    static final Logger LOGGER = LoggerFactory.getLogger(SchemaHandlerTest.class);
+    Vertx vertx;
+    Configuration config;
+    HttpServer server;
+    @TempDir
+    File dataDir0;
+    String testKeyspaceSchema;
+
+    @SuppressWarnings("DataFlowIssue")
+    @BeforeEach
+    void before() throws InterruptedException, IOException
+    {
+        ClassLoader cl = getClass().getClassLoader();
+        testKeyspaceSchema = IOUtils.toString(cl.getResourceAsStream("schema/test_keyspace_schema.cql"),
+                                              StandardCharsets.UTF_8);
+
+        Injector injector;
+        injector = Guice.createInjector(Modules.override(new MainModule())
+                                               .with(Modules.override(new TestModule())
+                                                            .with(new SchemaHandlerTestModule())));
+        vertx = injector.getInstance(Vertx.class);
+        server = injector.getInstance(HttpServer.class);
+        config = injector.getInstance(Configuration.class);
+        VertxTestContext context = new VertxTestContext();
+        server.listen(config.getPort(), config.getHost(), context.succeedingThenComplete());
+        context.awaitCompletion(5, TimeUnit.SECONDS);
+    }
+
+    @AfterEach
+    void after() 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.");
+    }
+
+    @Test
+    void testAllKeyspacesSchema(VertxTestContext context)
+    {
+        WebClient client = WebClient.create(vertx);
+        String testRoute = "/api/v1/schema/keyspaces";
+        client.get(config.getPort(), config.getHost(), testRoute)
+              .expect(ResponsePredicate.SC_OK)
+              .send(context.succeeding(response -> context.verify(() ->
+              {
+                  assertThat(response.statusCode()).isEqualTo(OK.code());
+                  JsonObject jsonObject = response.bodyAsJsonObject();
+                  assertThat(jsonObject.getString("keyspace")).isNull();
+                  assertThat(jsonObject.getString("schema"))
+                      .isEqualTo("FULL SCHEMA");
+                  context.completeNow();
+              })));
+    }
+
+    @Test
+    void testKeyspaceSchema(VertxTestContext context)
+    {
+        WebClient client = WebClient.create(vertx);
+        String testRoute = "/api/v1/schema/keyspaces/testKeyspace";
+        client.get(config.getPort(), config.getHost(), testRoute)
+              .expect(ResponsePredicate.SC_OK)
+              .send(context.succeeding(response -> context.verify(() ->
+              {
+                  assertThat(response.statusCode()).isEqualTo(OK.code());
+                  JsonObject jsonObject = response.bodyAsJsonObject();
+                  assertThat(jsonObject.getString("keyspace")).isEqualTo("testKeyspace");
+                  assertThat(jsonObject.getString("schema"))
+                      .isEqualTo(testKeyspaceSchema);
+                  context.completeNow();
+              })));
+    }
+
+    @Test
+    void testGetKeyspaceDoesNotExist(VertxTestContext context)
+    {
+        WebClient client = WebClient.create(vertx);
+        String testRoute = "/api/v1/schema/keyspaces/nonExistent";
+        client.get(config.getPort(), config.getHost(), testRoute)
+              .expect(ResponsePredicate.SC_NOT_FOUND)
+              .send(context.succeeding(response -> context.verify(() ->
+              {
+                  assertThat(response.statusCode()).isEqualTo(NOT_FOUND.code());
+                  context.completeNow();
+              })));
+    }
+
+    public class SchemaHandlerTestModule extends AbstractModule
+    {
+        @Provides
+        @Singleton
+        public InstancesConfig getInstanceConfig() throws IOException
+        {
+            final int instanceId = 100;
+            final String host = "127.0.0.1";
+            final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
+            when(instanceMetadata.host()).thenReturn(host);
+            when(instanceMetadata.port()).thenReturn(9042);
+            when(instanceMetadata.dataDirs()).thenReturn(Collections.singletonList(dataDir0.getCanonicalPath()));
+            when(instanceMetadata.id()).thenReturn(instanceId);
+            when(instanceMetadata.delegate()).thenReturn(mock(CassandraAdapterDelegate.class));
+            CQLSession mockCqlSession = mock(CQLSession.class);
+            Session mockSession = mock(Session.class);
+            when(mockCqlSession.getLocalCql()).thenReturn(mockSession);
+            Cluster mockCluster = mock(Cluster.class);
+            Metadata mockMetadata = mock(Metadata.class);
+            KeyspaceMetadata mockKeyspaceMetadata = mock(KeyspaceMetadata.class);
+            when(mockMetadata.exportSchemaAsString()).thenReturn("FULL SCHEMA");
+            when(mockMetadata.getKeyspace("testKeyspace")).thenReturn(mockKeyspaceMetadata);
+            when(mockKeyspaceMetadata.exportAsString()).thenReturn(testKeyspaceSchema);
+
+            when(mockCluster.getMetadata()).thenReturn(mockMetadata);
+            when(mockSession.getCluster()).thenReturn(mockCluster);
+            when(instanceMetadata.session()).thenReturn(mockCqlSession);
+
+            InstancesConfig mockInstancesConfig = mock(InstancesConfig.class);
+            when(mockInstancesConfig.instances()).thenReturn(Collections.singletonList(instanceMetadata));
+            when(mockInstancesConfig.instanceFromId(instanceId)).thenReturn(instanceMetadata);
+            when(mockInstancesConfig.instanceFromHost(host)).thenReturn(instanceMetadata);
+
+            return mockInstancesConfig;
+        }
+    }
+}
diff --git a/src/test/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandlerTest.java b/src/test/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandlerTest.java
index 2e788c6..61ba7ba 100644
--- a/src/test/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandlerTest.java
+++ b/src/test/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandlerTest.java
@@ -316,8 +316,8 @@ public class StreamSSTableComponentHandlerTest
                 {
                     assertThat(response.statusCode()).isEqualTo(OK.code());
                     assertThat(response.getHeader(HttpHeaderNames.CONTENT_LENGTH.toString()))
-                        .isEqualTo("4")
-                        .describedAs("Server should shrink the range to the file length");
+                        .describedAs("Server should shrink the range to the file length")
+                        .isEqualTo("4");
                     context.completeNow();
                 })));
     }
diff --git a/src/test/resources/schema/test_keyspace_schema.cql b/src/test/resources/schema/test_keyspace_schema.cql
new file mode 100644
index 0000000..aabf610
--- /dev/null
+++ b/src/test/resources/schema/test_keyspace_schema.cql
@@ -0,0 +1,63 @@
+CREATE KEYSPACE testkeyspace WITH REPLICATION = { 'class' : 'org.apache.cassandra.locator.SimpleStrategy', 'replication_factor': '1' }
+                             AND DURABLE_WRITES = true;
+
+CREATE TYPE testkeyspace.cidr_range (
+    id uuid,
+    start inet,
+    end inet,
+    description text
+    );
+
+CREATE TABLE testkeyspace.testtable
+(
+    partition_key_1     uuid,
+    partition_key_2     text,
+    partition_key_3     frozen<set<bigint>>,
+    clustering_column_1 date,
+    clustering_column_2 inet,
+    column_1            blob,
+    column_2            duration,
+    column_3            map<text, bigint>,
+    PRIMARY KEY ((partition_key_1, partition_key_2, partition_key_3),
+         clustering_column_1,
+         clustering_column_2
+        )
+) WITH CLUSTERING ORDER BY (clustering_column_1 ASC, clustering_column_2 ASC)
+   AND read_repair = 'BLOCKING'
+   AND gc_grace_seconds = 864000
+   AND additional_write_policy = '99p'
+   AND bloom_filter_fp_chance = 0.01
+   AND caching = { 'keys' : 'ALL', 'rows_per_partition' : 'NONE' }
+   AND comment = ''
+   AND compaction = { 'class' : 'org.apache.cassandra.db.compaction.SizeTieredCompactionStrategy', 'max_threshold' : 32, 'min_threshold' : 4 }
+   AND compression = { 'chunk_length_in_kb' : 16, 'class' : 'org.apache.cassandra.io.compress.LZ4Compressor' }
+   AND default_time_to_live = 0
+   AND speculative_retry = '99p'
+   AND min_index_interval = 128
+   AND max_index_interval = 2048
+   AND crc_check_chance = 1.0
+   AND cdc = false
+   AND memtable_flush_period_in_ms = 0;
+
+CREATE TABLE testkeyspace.testtable2
+(
+    partition_key_1 uuid,
+    partition_key_2 text,
+    column_1        testkeyspace.cidr_range,
+    PRIMARY KEY ((partition_key_1, partition_key_2)
+        )
+) WITH read_repair = 'BLOCKING'
+   AND gc_grace_seconds = 864000
+   AND additional_write_policy = '99p'
+   AND bloom_filter_fp_chance = 0.01
+   AND caching = { 'keys' : 'ALL', 'rows_per_partition' : 'NONE' }
+   AND comment = ''
+   AND compaction = { 'class' : 'org.apache.cassandra.db.compaction.SizeTieredCompactionStrategy', 'max_threshold' : 32, 'min_threshold' : 4 }
+   AND compression = { 'chunk_length_in_kb' : 16, 'class' : 'org.apache.cassandra.io.compress.LZ4Compressor' }
+   AND default_time_to_live = 0
+   AND speculative_retry = '99p'
+   AND min_index_interval = 128
+   AND max_index_interval = 2048
+   AND crc_check_chance = 1.0
+   AND cdc = false
+   AND memtable_flush_period_in_ms = 0;


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@cassandra.apache.org
For additional commands, e-mail: commits-help@cassandra.apache.org