This is an automated email from the ASF dual-hosted git repository.
alexey pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/kudu.git
The following commit(s) were added to refs/heads/master by this push:
new 9b92708a6 Add OpenAPI specification for REST API endpoints
9b92708a6 is described below
commit 9b92708a6544e9c4119a471b8c881e3689d3a8ec
Author: gabriellalotz <[email protected]>
AuthorDate: Thu May 8 14:22:21 2025 +0000
Add OpenAPI specification for REST API endpoints
Create a hand-written swagger/kudu-api.json specification rather than
implementing automated generation infrastructure. This approach was
chosen for several practical reasons:
- Kudu's REST API currently has only 3 main endpoints
(/api/v1/tables, /api/v1/tables/<table_id>, /api/v1/leader) with ~5
total method/path combinations. The overhead of automated generation
(C++ fluent APIs, annotation parsers, or complex macros) would
exceed the maintenance burden of manual updates.
- These endpoints are relatively stable and not changing frequently,
reducing the risk of documentation drift.
- A single JSON file is easier to review, debug, and modify than
custom C++ infrastructure or parsing scripts.
- No additional build steps, external tools, or runtime overhead. The
specification is immediately available when the server starts.
- If the API grows significantly or changes frequently, we can revisit
automated generation.
The OpenAPI 3.0 specification includes complete documentation for all
current endpoints with request/response schemas, parameter validation,
and error codes. It's served at /swagger/kudu-api.json and integrates
with the existing Swagger UI at /api/docs.
A Jira has been created for adding an automatic validation script
(https://issues.apache.org/jira/browse/KUDU-3700).
Change-Id: I3981665c78f478e89d0300f3a2fc5d68b73b8762
Reviewed-on: http://gerrit.cloudera.org:8080/23224
Reviewed-by: Alexey Serbin <[email protected]>
Tested-by: Alexey Serbin <[email protected]>
---
src/kudu/master/CMakeLists.txt | 2 +-
src/kudu/master/rest_catalog-test.cc | 91 +++
src/kudu/master/rest_catalog_path_handlers.cc | 55 ++
src/kudu/master/rest_catalog_path_handlers.h | 4 +
www/api/docs.mustache | 28 +
www/swagger/kudu-api.json | 962 ++++++++++++++++++++++++++
www/swagger/kudu-custom.css | 21 +
www/swagger/kudu-swagger-init.js | 70 ++
www/swagger/swagger-ui-bundle.js | 2 +
www/swagger/swagger-ui.css | 1 +
10 files changed, 1235 insertions(+), 1 deletion(-)
diff --git a/src/kudu/master/CMakeLists.txt b/src/kudu/master/CMakeLists.txt
index da2553b02..8aa16eaa2 100644
--- a/src/kudu/master/CMakeLists.txt
+++ b/src/kudu/master/CMakeLists.txt
@@ -145,7 +145,7 @@ ADD_KUDU_TEST(master-test RESOURCE_LOCK "master-web-port"
DATA_FILES ../scripts/first_argument.sh)
ADD_KUDU_TEST(mini_master-test RESOURCE_LOCK "master-web-port")
ADD_KUDU_TEST(placement_policy-test)
-ADD_KUDU_TEST(rest_catalog-test)
+ADD_KUDU_TEST(rest_catalog-test DATA_FILES ../../../www)
ADD_KUDU_TEST(spnego_rest_catalog-test)
ADD_KUDU_TEST(sys_catalog-test RESOURCE_LOCK "master-web-port")
ADD_KUDU_TEST(ts_descriptor-test DATA_FILES ../scripts/first_argument.sh)
diff --git a/src/kudu/master/rest_catalog-test.cc
b/src/kudu/master/rest_catalog-test.cc
index cf29ca0ad..8b1874ec3 100644
--- a/src/kudu/master/rest_catalog-test.cc
+++ b/src/kudu/master/rest_catalog-test.cc
@@ -23,6 +23,7 @@
#include <gflags/gflags_declare.h>
#include <gtest/gtest.h>
+#include <rapidjson/document.h>
#include "kudu/client/client.h"
#include "kudu/client/schema.h"
@@ -32,8 +33,11 @@
#include "kudu/master/rest_catalog_test_base.h"
#include "kudu/mini-cluster/internal_mini_cluster.h"
#include "kudu/util/curl_util.h"
+#include "kudu/util/env.h"
#include "kudu/util/faststring.h"
+#include "kudu/util/jsonreader.h"
#include "kudu/util/net/sockaddr.h"
+#include "kudu/util/path_util.h"
#include "kudu/util/regex.h"
#include "kudu/util/status.h"
#include "kudu/util/test_macros.h"
@@ -46,6 +50,7 @@ using kudu::client::KuduTable;
using kudu::client::sp::shared_ptr;
using kudu::cluster::InternalMiniCluster;
using kudu::cluster::InternalMiniClusterOptions;
+using rapidjson::Value;
using std::set;
using std::string;
using std::unique_ptr;
@@ -53,6 +58,7 @@ using std::vector;
using strings::Substitute;
DECLARE_bool(enable_rest_api);
+DECLARE_string(webserver_doc_root);
namespace kudu {
namespace master {
@@ -64,6 +70,11 @@ class RestCatalogTest : public RestCatalogTestBase {
// Set REST endpoint flag to true
FLAGS_enable_rest_api = true;
+ // Set webserver doc root to enable swagger UI and static file serving
+ string bin_path;
+ ASSERT_OK(Env::Default()->GetExecutablePath(&bin_path));
+ FLAGS_webserver_doc_root = JoinPathSegments(DirName(bin_path),
"testdata/www");
+
// Configure the mini-cluster
cluster_.reset(new InternalMiniCluster(env_,
InternalMiniClusterOptions()));
@@ -860,5 +871,85 @@ TEST_F(MultiMasterTest, TestGetLeaderEndpoint) {
ASSERT_STR_CONTAINS(leader_buf.ToString(), "{\"tables\":[]}");
}
+TEST_F(RestCatalogTest, TestApiSpecEndpoint) {
+ EasyCurl c;
+ faststring buf;
+ ASSERT_OK(c.FetchURL(
+ Substitute("http://$0/api/v1/spec",
cluster_->mini_master()->bound_http_addr().ToString()),
+ &buf));
+
+ string spec_json = buf.ToString();
+ ASSERT_FALSE(spec_json.empty()) << "API spec should not be empty";
+
+ JsonReader reader(spec_json);
+ ASSERT_OK(reader.Init());
+
+ // Helper lambda to verify an object exists in parent
+ auto VerifyObjectExists = [&reader](const Value* parent, const char* field) {
+ const Value* obj;
+ Status s = reader.ExtractObject(parent, field, &obj);
+ ASSERT_TRUE(s.ok()) << "Field '" << field << "' not found: " <<
s.ToString();
+ };
+
+ string openapi_version;
+ ASSERT_OK(reader.ExtractString(reader.root(), "openapi", &openapi_version));
+ ASSERT_FALSE(openapi_version.empty());
+
+ const Value* paths;
+ ASSERT_OK(reader.ExtractObject(reader.root(), "paths", &paths));
+
+ const Value* tables_path;
+ ASSERT_OK(reader.ExtractObject(paths, "/tables", &tables_path));
+ NO_FATALS(VerifyObjectExists(tables_path, "get"));
+ NO_FATALS(VerifyObjectExists(tables_path, "post"));
+
+ const Value* table_by_id_path;
+ ASSERT_OK(reader.ExtractObject(paths, "/tables/{table_id}",
&table_by_id_path));
+ NO_FATALS(VerifyObjectExists(table_by_id_path, "get"));
+ NO_FATALS(VerifyObjectExists(table_by_id_path, "put"));
+ NO_FATALS(VerifyObjectExists(table_by_id_path, "delete"));
+
+ const Value* leader_path;
+ ASSERT_OK(reader.ExtractObject(paths, "/leader", &leader_path));
+ NO_FATALS(VerifyObjectExists(leader_path, "get"));
+
+ const Value* components;
+ ASSERT_OK(reader.ExtractObject(reader.root(), "components", &components));
+ const Value* schemas;
+ ASSERT_OK(reader.ExtractObject(components, "schemas", &schemas));
+
+ const vector<string> expected_schemas = {"TablesResponse", "TableInfo",
"TableResponse",
+ "LeaderResponse", "ErrorResponse",
"TableSchema",
+ "PartitionSchema",
"CreateTableRequest",
+ "AlterTableRequest"};
+ for (const auto& schema_name : expected_schemas) {
+ NO_FATALS(VerifyObjectExists(schemas, schema_name.c_str()));
+ }
+}
+
+TEST_F(RestCatalogTest, TestApiDocsEndpoint) {
+ EasyCurl c;
+ faststring buf;
+ Status s = c.FetchURL(
+ Substitute("http://$0/api/docs",
cluster_->mini_master()->bound_http_addr().ToString()),
+ &buf);
+
+ ASSERT_TRUE(s.ok()) << "API docs endpoint should return HTTP 200: " <<
s.ToString();
+
+ string content = buf.ToString();
+ ASSERT_FALSE(content.empty()) << "API docs should not be empty";
+
+ ASSERT_STR_CONTAINS(content, "swagger-ui.css") << "Should include Swagger UI
CSS";
+ ASSERT_STR_CONTAINS(content, "swagger-ui-bundle.js") << "Should include
Swagger UI JS bundle";
+ ASSERT_STR_CONTAINS(content, "kudu-swagger-init.js") << "Should include Kudu
Swagger init script";
+
+ ASSERT_STR_CONTAINS(content, "id=\"swagger-ui\"") << "Should have Swagger UI
container div";
+
+ // Verify it has loading or ready state text
+ bool has_swagger_content =
+ content.find("swagger-ui") != string::npos || content.find("API") !=
string::npos;
+ ASSERT_TRUE(has_swagger_content) << "Should contain Swagger UI or
API-related content";
+}
+
} // namespace master
} // namespace kudu
diff --git a/src/kudu/master/rest_catalog_path_handlers.cc
b/src/kudu/master/rest_catalog_path_handlers.cc
index c0c81ce2d..98a7cd4a3 100644
--- a/src/kudu/master/rest_catalog_path_handlers.cc
+++ b/src/kudu/master/rest_catalog_path_handlers.cc
@@ -19,6 +19,7 @@
#include <functional>
#include <optional>
+#include <ostream>
#include <string>
#include <unordered_map>
#include <utility>
@@ -37,12 +38,16 @@
#include "kudu/master/master.h"
#include "kudu/master/master.pb.h"
#include "kudu/util/cow_object.h"
+#include "kudu/util/env.h"
+#include "kudu/util/faststring.h"
#include "kudu/util/flag_tags.h"
#include "kudu/util/jsonwriter.h"
#include "kudu/util/monotime.h"
#include "kudu/util/status.h"
#include "kudu/util/web_callback_registry.h"
+DECLARE_string(webserver_doc_root);
+
// We only use macros here to maintain cohesion with the existing
RETURN_NOT_OK-style pattern.
// They provide a consistent way to return JSON-formatted error responses.
#define RETURN_JSON_ERROR(jw, error_msg, status_code, error_code) \
@@ -219,6 +224,40 @@ void RestCatalogPathHandlers::HandleLeaderEndpoint(const
Webserver::WebRequest&
RETURN_JSON_ERROR(jw, "No leader master found", resp->status_code,
HttpStatusCode::NotFound);
}
+// Kept as instance method for consistency with other handlers in this class.
+void RestCatalogPathHandlers::HandleApiDocsEndpoint(const
Webserver::WebRequest& req, // NOLINT
+ Webserver::WebResponse*
resp) {
+ if (req.request_method != "GET") {
+ resp->status_code = HttpStatusCode::MethodNotAllowed;
+ return;
+ }
+
+ resp->status_code = HttpStatusCode::Ok;
+}
+
+// Kept as instance method for consistency with other handlers in this class.
+void RestCatalogPathHandlers::HandleApiSpecEndpoint(const
Webserver::WebRequest& req, // NOLINT
+
Webserver::PrerenderedWebResponse* resp) {
+ if (req.request_method != "GET") {
+ resp->status_code = HttpStatusCode::MethodNotAllowed;
+ resp->output << "Method not allowed";
+ return;
+ }
+
+ const string spec_file_path = Substitute("$0/swagger/kudu-api.json",
FLAGS_webserver_doc_root);
+ faststring spec_content;
+ Status s = ReadFileToString(Env::Default(), spec_file_path, &spec_content);
+
+ if (!s.ok()) {
+ resp->status_code = HttpStatusCode::NotFound;
+ resp->output << Substitute("Could not read API specification: $0",
s.ToString());
+ return;
+ }
+
+ resp->status_code = HttpStatusCode::Ok;
+ resp->output << spec_content.ToString();
+}
+
void RestCatalogPathHandlers::HandleGetTables(std::ostringstream* output,
const Webserver::WebRequest& req,
HttpStatusCode* status_code) {
@@ -449,6 +488,22 @@ void RestCatalogPathHandlers::Register(Webserver* server) {
},
StyleMode::JSON,
false);
+ server->RegisterPathHandler(
+ "/api/docs",
+ "REST API Docs",
+ [this](const Webserver::WebRequest& req, Webserver::WebResponse* resp) {
+ this->HandleApiDocsEndpoint(req, resp);
+ },
+ StyleMode::STYLED,
+ true);
+ server->RegisterPrerenderedPathHandler(
+ "/api/v1/spec",
+ "",
+ [this](const Webserver::WebRequest& req,
Webserver::PrerenderedWebResponse* resp) {
+ this->HandleApiSpecEndpoint(req, resp);
+ },
+ StyleMode::JSON,
+ false);
}
} // namespace master
diff --git a/src/kudu/master/rest_catalog_path_handlers.h
b/src/kudu/master/rest_catalog_path_handlers.h
index 89c51065a..982f8297f 100644
--- a/src/kudu/master/rest_catalog_path_handlers.h
+++ b/src/kudu/master/rest_catalog_path_handlers.h
@@ -47,6 +47,10 @@ class RestCatalogPathHandlers final {
Webserver::PrerenderedWebResponse* resp);
void HandleLeaderEndpoint(const Webserver::WebRequest& req,
Webserver::PrerenderedWebResponse* resp);
+ void HandleApiDocsEndpoint(const Webserver::WebRequest& req,
+ Webserver::WebResponse* resp);
+ void HandleApiSpecEndpoint(const Webserver::WebRequest& req,
+ Webserver::PrerenderedWebResponse* resp);
// Handles REST API endpoints based on the request method and path.
void HandleGetTables(std::ostringstream* output,
diff --git a/www/api/docs.mustache b/www/api/docs.mustache
new file mode 100644
index 000000000..381ff12e3
--- /dev/null
+++ b/www/api/docs.mustache
@@ -0,0 +1,28 @@
+{{!
+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.
+}}
+
+<link rel="stylesheet" type="text/css"
href="{{base_url}}/swagger/swagger-ui.css" />
+<link rel="stylesheet" type="text/css"
href="{{base_url}}/swagger/kudu-custom.css" />
+
+<div id="swagger-ui" data-base-url="{{base_url}}">
+ <p>Loading Swagger UI...</p>
+</div>
+
+<script src="{{base_url}}/swagger/swagger-ui-bundle.js"
charset="UTF-8"></script>
+<script src="{{base_url}}/swagger/kudu-swagger-init.js"
charset="UTF-8"></script>
diff --git a/www/swagger/kudu-api.json b/www/swagger/kudu-api.json
new file mode 100644
index 000000000..4a6910571
--- /dev/null
+++ b/www/swagger/kudu-api.json
@@ -0,0 +1,962 @@
+{
+ "openapi": "3.0.3",
+ "info": {
+ "title": "Kudu Master REST API",
+ "version": "1.0.0"
+ },
+ "servers": [
+ {
+ "url": "/api/v1",
+ "description": "Kudu Master REST API v1"
+ }
+ ],
+ "paths": {
+ "/tables": {
+ "get": {
+ "summary": "List all tables",
+ "description": "Retrieve a list of all tables in the Kudu cluster",
+ "operationId": "listTables",
+ "responses": {
+ "200": {
+ "description": "Successfully retrieved list of tables",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TablesResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Authentication required - SPNEGO authentication
failed or missing (secured clusters only)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Service unavailable - master not ready or not
leader",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "summary": "Create a new table",
+ "description": "Create a new table in the Kudu cluster",
+ "operationId": "createTable",
+ "requestBody": {
+ "description": "Table creation request",
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateTableRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Table created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TableResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - invalid table specification",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ },
+ "examples": {
+ "invalid_json": {
+ "summary": "Invalid JSON format",
+ "value": {
+ "error": "JSON table object is not correct:
{\"name\":\"test_table\"}"
+ }
+ },
+ "missing_schema": {
+ "summary": "Missing required schema",
+ "value": {
+ "error": "JSON table object is not correct:
{\"name\":\"test_table\",\"partition_schema\":{}}"
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Authentication required - SPNEGO authentication
failed or missing (secured clusters only)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Service unavailable - master not ready or not
leader",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/tables/{table_id}": {
+ "get": {
+ "summary": "Get table details",
+ "description": "Retrieve detailed information about a specific table",
+ "operationId": "getTable",
+ "parameters": [
+ {
+ "name": "table_id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the table (32
characters)",
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{32}$",
+ "minLength": 32,
+ "maxLength": 32
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successfully retrieved table details",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TableResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - invalid table ID format",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Authentication required - SPNEGO authentication
failed or missing (secured clusters only)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Table not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Service unavailable - master not ready or not
leader",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "summary": "Update table",
+ "description": "Modify an existing table (alter table operation)",
+ "operationId": "updateTable",
+ "parameters": [
+ {
+ "name": "table_id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the table (32
characters)",
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{32}$",
+ "minLength": 32,
+ "maxLength": 32
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Table alteration request",
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AlterTableRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Table updated successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TableResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - invalid table ID or alteration
request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Authentication required - SPNEGO authentication
failed or missing (secured clusters only)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Table not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Service unavailable - master not ready or not
leader",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "summary": "Delete table",
+ "description": "Remove a table from the Kudu cluster",
+ "operationId": "deleteTable",
+ "parameters": [
+ {
+ "name": "table_id",
+ "in": "path",
+ "required": true,
+ "description": "The unique identifier of the table (32
characters)",
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{32}$",
+ "minLength": 32,
+ "maxLength": 32
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Table deleted successfully"
+ },
+ "400": {
+ "description": "Bad request - invalid table ID format",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Authentication required - SPNEGO authentication
failed or missing (secured clusters only)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Table not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Service unavailable - master not ready or not
leader",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/leader": {
+ "get": {
+ "summary": "Get leader master information",
+ "description": "Retrieve information about the current leader master",
+ "operationId": "getLeaderMaster",
+ "responses": {
+ "200": {
+ "description": "Successfully retrieved leader master information",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LeaderResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Authentication required - SPNEGO authentication
failed or missing (secured clusters only)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No leader master found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "TablesResponse": {
+ "type": "object",
+ "properties": {
+ "tables": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TableInfo"
+ }
+ }
+ },
+ "required": ["tables"]
+ },
+ "TableInfo": {
+ "type": "object",
+ "properties": {
+ "table_id": {
+ "type": "string",
+ "description": "Unique identifier of the table",
+ "pattern": "^[a-f0-9]{32}$"
+ },
+ "table_name": {
+ "type": "string",
+ "description": "Name of the table"
+ }
+ },
+ "required": ["table_id", "table_name"]
+ },
+ "TableResponse": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier of the table",
+ "pattern": "^[a-f0-9]{32}$"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the table"
+ },
+ "schema": {
+ "$ref": "#/components/schemas/TableSchema"
+ },
+ "partition_schema": {
+ "$ref": "#/components/schemas/PartitionSchema"
+ },
+ "owner": {
+ "type": "string",
+ "description": "Owner of the table"
+ },
+ "comment": {
+ "type": "string",
+ "description": "Comment associated with the table"
+ },
+ "extra_config": {
+ "type": "object",
+ "description": "Additional configuration for the table"
+ }
+ },
+ "required": ["id", "name", "schema", "partition_schema", "owner"]
+ },
+ "TableSchema": {
+ "type": "object",
+ "properties": {
+ "columns": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ColumnSchema"
+ }
+ }
+ },
+ "required": ["columns"]
+ },
+ "ColumnSchema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Column name",
+ "default": "new_column_name"
+ },
+ "type": {
+ "type": "string",
+ "description": "Column data type",
+ "enum": [
+ "INT8", "INT16", "INT32", "INT64", "UNIXTIME_MICROS",
+ "FLOAT", "DOUBLE", "DECIMAL", "STRING", "BOOL", "BINARY",
+ "VARCHAR", "DATE", "NESTED"
+ ]
+ },
+ "is_key": {
+ "type": "boolean",
+ "description": "Whether this column is part of the primary key"
+ },
+ "is_nullable": {
+ "type": "boolean",
+ "description": "Whether this column can contain NULL values",
+ "default": false
+ },
+ "is_auto_incrementing": {
+ "type": "boolean",
+ "description": "Whether this column is auto-incrementing",
+ "default": false
+ },
+ "nested_type": {
+ "$ref": "#/components/schemas/NestedDataType",
+ "description": "Descriptor of the nested data type. Required when
type is NESTED."
+ }
+ },
+ "required": ["name", "type", "is_key", "is_nullable"]
+ },
+ "NestedDataType": {
+ "type": "object",
+ "properties": {
+ "array": {
+ "$ref": "#/components/schemas/ArrayTypeDescriptor"
+ }
+ },
+ "description": "Nested data type descriptor. Currently only arrays of
scalar types are supported."
+ },
+ "ArrayTypeDescriptor": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Element type of the array",
+ "enum": [
+ "INT8", "INT16", "INT32", "INT64", "UNIXTIME_MICROS",
+ "FLOAT", "DOUBLE", "DECIMAL", "STRING", "BOOL", "BINARY",
+ "VARCHAR", "DATE"
+ ]
+ }
+ },
+ "required": ["type"]
+ },
+ "PartitionSchema": {
+ "type": "object",
+ "properties": {
+ "hash_schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/HashBucketSchema"
+ },
+ "description": "Table-wide hash schema. Hash schema for a
particular range may be overridden by corresponding element in
custom_hash_schema_ranges."
+ },
+ "range_schema": {
+ "$ref": "#/components/schemas/RangeSchema"
+ },
+ "custom_hash_schema_ranges": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/RangeWithHashSchema"
+ },
+ "description": "Ranges with custom hash schemas that override the
table-wide hash schema for specific ranges"
+ }
+ }
+ },
+ "RangeWithHashSchema": {
+ "type": "object",
+ "properties": {
+ "range_bounds": {
+ "$ref": "#/components/schemas/RowOperations",
+ "description": "Range bounds (RANGE_LOWER_BOUND and
RANGE_UPPER_BOUND operations)"
+ },
+ "hash_schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/HashBucketSchema"
+ },
+ "description": "Hash schema for this specific range"
+ }
+ }
+ },
+ "HashBucketSchema": {
+ "type": "object",
+ "properties": {
+ "columns": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ColumnIdentifier"
+ },
+ "description": "Column identifiers of columns included in the
hash. Every column must be a component of the primary key."
+ },
+ "num_buckets": {
+ "type": "integer",
+ "minimum": 2,
+ "description": "Number of buckets into which columns will be
hashed. Must be at least 2."
+ },
+ "seed": {
+ "type": "integer",
+ "format": "int32",
+ "description": "Seed value for hash calculation. Optional."
+ }
+ },
+ "required": ["columns", "num_buckets"]
+ },
+ "RangeSchema": {
+ "type": "object",
+ "properties": {
+ "columns": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ColumnIdentifier"
+ },
+ "description": "Column identifiers of columns included in the
range. All columns must be a component of the primary key."
+ }
+ },
+ "required": ["columns"]
+ },
+ "ColumnIdentifier": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Column name"
+ }
+ },
+ "description": "Column identifier by name. Used in partition schemas
to specify which columns participate in partitioning."
+ },
+ "CreateTableRequest": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name of the table to create"
+ },
+ "schema": {
+ "$ref": "#/components/schemas/TableSchema"
+ },
+ "partition_schema": {
+ "$ref": "#/components/schemas/PartitionSchema"
+ },
+ "num_replicas": {
+ "type": "integer",
+ "enum": [1, 3, 5, 7],
+ "default": 3,
+ "description": "Number of replicas for the table. Must be an odd
number. Typical values are 1, 3, 5, or 7, though actual limits may vary based
on cluster configuration."
+ },
+ "owner": {
+ "type": "string",
+ "description": "Owner of the table"
+ },
+ "comment": {
+ "type": "string",
+ "description": "Comment for the table"
+ }
+ },
+ "required": ["name", "schema", "partition_schema"]
+ },
+ "AlterTableRequest": {
+ "type": "object",
+ "properties": {
+ "table": {
+ "$ref": "#/components/schemas/TableIdentifier"
+ },
+ "alter_schema_steps": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/AlterTableStep"
+ }
+ },
+ "new_table_name": {
+ "type": "string",
+ "description": "New name for the table",
+ "default": "new_table_name"
+ },
+ "new_table_owner": {
+ "type": "string",
+ "description": "New owner for the table",
+ "default": "new_table_owner"
+ },
+ "new_table_comment": {
+ "type": "string",
+ "description": "New comment for the table",
+ "default": "new_table_comment"
+ },
+ "num_replicas": {
+ "type": "integer",
+ "enum": [1, 3, 5, 7],
+ "default": 3,
+ "description": "New replication factor for the table. Must be an
odd number. Typical values are 1, 3, 5, or 7, though actual limits may vary
based on cluster configuration."
+ },
+ "schema": {
+ "$ref": "#/components/schemas/TableSchema",
+ "description": "Current table schema (required when
adding/dropping range partitions)"
+ },
+ "new_extra_configs": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "New extra configuration properties for the table"
+ }
+ }
+ },
+ "TableIdentifier": {
+ "type": "object",
+ "properties": {
+ "table_name": {
+ "type": "string",
+ "description": "Name of the table"
+ }
+ },
+ "description": "Table identifier - specify either table_id OR
table_name"
+ },
+ "AlterTableStep": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "ADD_COLUMN",
+ "DROP_COLUMN",
+ "RENAME_COLUMN",
+ "ALTER_COLUMN",
+ "ADD_RANGE_PARTITION",
+ "DROP_RANGE_PARTITION"
+ ]
+ },
+ "add_column": {
+ "$ref": "#/components/schemas/AddColumnSpec"
+ },
+ "drop_column": {
+ "$ref": "#/components/schemas/DropColumnSpec"
+ },
+ "rename_column": {
+ "$ref": "#/components/schemas/RenameColumnSpec"
+ },
+ "alter_column": {
+ "$ref": "#/components/schemas/AlterColumnSpec"
+ },
+ "add_range_partition": {
+ "$ref": "#/components/schemas/AddRangePartitionSpec"
+ },
+ "drop_range_partition": {
+ "$ref": "#/components/schemas/DropRangePartitionSpec"
+ }
+ },
+ "required": ["type"],
+ "description": "Alter table step - exactly one operation field must be
set based on type"
+ },
+ "AddColumnSpec": {
+ "type": "object",
+ "properties": {
+ "schema": {
+ "$ref": "#/components/schemas/ColumnSchema"
+ }
+ },
+ "required": ["schema"]
+ },
+ "DropColumnSpec": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name of the column to drop"
+ }
+ },
+ "required": ["name"]
+ },
+ "RenameColumnSpec": {
+ "type": "object",
+ "properties": {
+ "old_name": {
+ "type": "string",
+ "description": "Current name of the column"
+ },
+ "new_name": {
+ "type": "string",
+ "description": "New name for the column"
+ }
+ },
+ "required": ["old_name", "new_name"]
+ },
+ "AlterColumnSpec": {
+ "type": "object",
+ "properties": {
+ "delta": {
+ "$ref": "#/components/schemas/ColumnSchemaDelta"
+ }
+ },
+ "required": ["delta"]
+ },
+ "ColumnSchemaDelta": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Column name to alter"
+ },
+ "new_name": {
+ "type": "string",
+ "description": "New column name"
+ },
+ "default_value": {
+ "type": "string",
+ "format": "byte",
+ "description": "New default value (encoded)"
+ },
+ "remove_default": {
+ "type": "boolean",
+ "description": "Whether to remove the default value"
+ },
+ "new_comment": {
+ "type": "string",
+ "description": "New comment for the column"
+ }
+ }
+ },
+ "AddRangePartitionSpec": {
+ "type": "object",
+ "properties": {
+ "range_bounds": {
+ "$ref": "#/components/schemas/RowOperations",
+ "description": "Range bounds for the partition (RANGE_LOWER_BOUND
and RANGE_UPPER_BOUND operations)"
+ },
+ "dimension_label": {
+ "type": "string",
+ "description": "Dimension label for tablet placement"
+ },
+ "custom_hash_schema": {
+ "$ref": "#/components/schemas/CustomHashSchema"
+ }
+ }
+ },
+ "DropRangePartitionSpec": {
+ "type": "object",
+ "properties": {
+ "range_bounds": {
+ "$ref": "#/components/schemas/RowOperations",
+ "description": "Range bounds for the partition to drop"
+ }
+ }
+ },
+ "CustomHashSchema": {
+ "type": "object",
+ "properties": {
+ "hash_schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/HashBucketSchema"
+ }
+ }
+ }
+ },
+ "RowOperations": {
+ "type": "object",
+ "description": "Row operations for range bounds - complex format, see
protobuf documentation"
+ },
+ "LeaderResponse": {
+ "type": "object",
+ "properties": {
+ "leader": {
+ "type": "string",
+ "description": "URL of the leader master",
+ "format": "uri"
+ }
+ },
+ "required": ["leader"]
+ },
+ "ErrorResponse": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "Error message describing what went wrong"
+ }
+ },
+ "required": ["error"],
+ "examples": {
+ "table_not_found": {
+ "summary": "Table not found",
+ "value": {
+ "error": "Table not found"
+ }
+ },
+ "invalid_table_id": {
+ "summary": "Invalid table ID",
+ "value": {
+ "error": "Invalid table ID: must be exactly 32 characters long."
+ }
+ },
+ "invalid_json": {
+ "summary": "Invalid JSON format",
+ "value": {
+ "error": "JSON table object is not correct:
{\"name\":\"test_table\"}"
+ }
+ },
+ "method_not_allowed": {
+ "summary": "HTTP method not allowed",
+ "value": {
+ "error": "Method not allowed"
+ }
+ },
+ "master_not_ready": {
+ "summary": "Master not ready",
+ "value": {
+ "error": "Master is not ready: CatalogManager is not running"
+ }
+ },
+ "no_leader": {
+ "summary": "No leader master found",
+ "value": {
+ "error": "No leader master found"
+ }
+ }
+ }
+ }
+ },
+ "securitySchemes": {
+ "spnego": {
+ "type": "http",
+ "scheme": "bearer",
+ "description": "**SPNEGO/Kerberos Authentication (Not interactive):**
When accessing a secured Kudu cluster, SPNEGO authentication using Kerberos is
required. Clients must obtain a valid Kerberos ticket and present it via HTTP
Negotiate (RFC 4559) authentication. This authentication scheme cannot be
configured through Swagger UI - clients must handle SPNEGO authentication in
their HTTP client (e.g., curl with --negotiate flag)."
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/www/swagger/kudu-custom.css b/www/swagger/kudu-custom.css
new file mode 100644
index 000000000..88f7b8311
--- /dev/null
+++ b/www/swagger/kudu-custom.css
@@ -0,0 +1,21 @@
+/* Hide any navbar/header elements */
+.swagger-ui .topbar,
+.swagger-ui .swagger-ui-wrap > .topbar,
+.swagger-ui .header,
+.swagger-ui .info .title,
+.swagger-ui .info hgroup.main {
+ display: none !important;
+}
+
+.swagger-ui {
+ max-width: none !important;
+}
+
+.swagger-ui .wrapper {
+ padding-top: 0 !important;
+}
+
+.swagger-ui .scheme-container {
+ margin-top: 0 !important;
+ padding-top: 0 !important;
+}
diff --git a/www/swagger/kudu-swagger-init.js b/www/swagger/kudu-swagger-init.js
new file mode 100644
index 000000000..3d1689618
--- /dev/null
+++ b/www/swagger/kudu-swagger-init.js
@@ -0,0 +1,70 @@
+function initializeSwaggerUI() {
+ console.log('Initializing Swagger UI...');
+ console.log('SwaggerUIBundle available:', typeof SwaggerUIBundle !==
'undefined');
+
+ if (typeof SwaggerUIBundle === 'undefined') {
+ document.getElementById('swagger-ui').innerHTML = '<div style="color: red;
padding: 20px; border: 1px solid red;">ERROR: SwaggerUIBundle is not loaded!
Check browser console for details.</div>';
+ return;
+ }
+
+ try {
+ const swaggerContainer = document.getElementById('swagger-ui');
+ const baseUrl = swaggerContainer ?
swaggerContainer.getAttribute('data-base-url') || '' : '';
+ console.log('Base URL:', baseUrl);
+
+ let specUrl;
+
+ if (baseUrl && baseUrl.includes('?')) {
+ const [basePath, queryParams] = baseUrl.split('?', 2);
+ // Remove trailing slashes to prevent double slashes
+ const cleanBasePath = basePath.replace(/\/+$/, '');
+ specUrl = `${cleanBasePath}/api/v1/spec?${queryParams}`;
+ }
+ else {
+ const cleanBasePath = baseUrl.replace(/\/+$/, '');
+ specUrl = `${cleanBasePath}/api/v1/spec`;
+ }
+ console.log('API spec URL:', specUrl);
+
+ const ui = SwaggerUIBundle({
+ url: specUrl,
+ dom_id: '#swagger-ui',
+ deepLinking: true,
+ presets: [
+ SwaggerUIBundle.presets.apis
+ ],
+ plugins: [
+ SwaggerUIBundle.plugins.DownloadUrl
+ ],
+ layout: "BaseLayout",
+ onComplete: function() {
+ const hideAuthElements = function() {
+ const modalAuthBtn = document.querySelector('.auth-btn-wrapper
.btn.authorize');
+ if (modalAuthBtn) {
+ modalAuthBtn.style.display = 'none';
+ }
+
+ const authInputWrapper = document.querySelector('.auth-container
input');
+ if (authInputWrapper) {
+ const wrapper = authInputWrapper.closest('.wrapper');
+ if (wrapper) {
+ wrapper.style.display = 'none';
+ }
+ }
+ };
+
+ hideAuthElements();
+ const observer = new MutationObserver(hideAuthElements);
+ observer.observe(document.body, { childList: true, subtree: true });
+ }
+ });
+
+ console.log('Swagger UI initialized successfully');
+ } catch (error) {
+ console.error('Error initializing Swagger UI:', error);
+ document.getElementById('swagger-ui').innerHTML = '<div style="color: red;
padding: 20px; border: 1px solid red;">ERROR: ' + error.message + '</div>';
+ }
+}
+
+// Initialize when DOM is ready
+window.addEventListener('load', initializeSwaggerUI);
\ No newline at end of file
diff --git a/www/swagger/swagger-ui-bundle.js b/www/swagger/swagger-ui-bundle.js
new file mode 100644
index 000000000..419ffe6fa
--- /dev/null
+++ b/www/swagger/swagger-ui-bundle.js
@@ -0,0 +1,2 @@
+/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */
+!function webpackUniversalModuleDefinition(s,o){"object"==typeof
exports&&"object"==typeof module?module.exports=o():"function"==typeof
define&&define.amd?define([],o):"object"==typeof
exports?exports.SwaggerUIBundle=o():s.SwaggerUIBundle=o()}(this,(()=>(()=>{var
s={251:(s,o)=>{o.read=function(s,o,i,a,u){var
_,w,x=8*u-a-1,C=(1<<x)-1,j=C>>1,L=-7,B=i?u-1:0,$=i?-1:1,V=s[o+B];for(B+=$,_=V&(1<<-L)-1,V>>=-L,L+=x;L>0;_=256*_+s[o+B],B+=$,L-=8);for(w=_&(1<<-L)-1,_>>=-L,L+=a;L>0;w=256*w+s[o+B],B+=
[...]
":95===s?" ":76===s?"\u2028":80===s?"\u2029":""}function
charFromCodepoint(s){return
s<=65535?String.fromCharCode(s):String.fromCharCode(55296+(s-65536>>10),56320+(s-65536&1023))}for(var
$r=new Array(256),Vr=new
Array(256),Ur=0;Ur<256;Ur++)$r[Ur]=simpleEscapeSequence(Ur)?1:0,Vr[Ur]=simpleEscapeSequence(Ur);function
State$1(s,o){this.input=s,this.filename=o.filename||null,this.schema=o.schema||Mr,this.onWarning=o.onWarning||null,this.legacy=o.legacy||!1,this.json=o.json||!1,this.listener=o
[...]
\ No newline at end of file
diff --git a/www/swagger/swagger-ui.css b/www/swagger/swagger-ui.css
new file mode 100644
index 000000000..384ce28ae
--- /dev/null
+++ b/www/swagger/swagger-ui.css
@@ -0,0 +1 @@
+.swagger-ui{color:#3b4151;font-family:sans-serif}.swagger-ui
html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}.swagger-ui
body{margin:0}.swagger-ui article,.swagger-ui aside,.swagger-ui
footer,.swagger-ui header,.swagger-ui nav,.swagger-ui
section{display:block}.swagger-ui h1{font-size:2em;margin:.67em 0}.swagger-ui
figcaption,.swagger-ui figure,.swagger-ui main{display:block}.swagger-ui
figure{margin:1em 40px}.swagger-ui hr{box-sizing:content-box;height:0;ov [...]