blob: fda03cc4a158fa49b946eaa66ab1f2622a3f4692 [file]
/*
* Copyright 2026-present Alibaba Inc.
*
* Licensed 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.
*/
#include "paimon/rest/rest_catalog.h"
#include <algorithm>
#include <atomic>
#include <cstring>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "arrow/api.h"
#include "arrow/c/bridge.h"
#include "fmt/format.h"
#include "gtest/gtest.h"
#include "paimon/catalog/catalog.h"
#include "paimon/catalog/table.h"
#include "paimon/catalog_options.h"
#include "paimon/common/utils/checked_cast.h"
#include "paimon/common/utils/string_utils.h"
#include "paimon/core/schema/table_schema.h"
#include "paimon/defs.h"
#include "paimon/rest/mock_rest_server.h"
#include "paimon/rest/rest_api.h"
#include "paimon/schema/schema.h"
#include "paimon/testing/utils/testharness.h"
namespace paimon::test {
namespace {
constexpr const char kToken[] = "test-token";
constexpr const char kPrefix[] = "paimon";
constexpr const char kWarehouse[] = "wh1";
// The in-memory catalog state behind the mock rest server.
struct MockCatalogState {
struct TableData {
std::string schema_json;
int64_t schema_id = 0;
std::string path;
};
std::map<std::string, std::map<std::string, TableData>> databases;
// headers of the last request, with lower-cased names
std::map<std::string, std::string> last_headers;
// when set, every request except "/v1/config" fails with this http code
std::optional<int32_t> force_error_code;
// guards all fields above: the handler runs on the server's accept thread while
// tests seed and inspect the state
std::mutex mutex;
};
MockRestServer::Response JsonResponse(int32_t code, const std::string& body) {
MockRestServer::Response response;
response.code = code;
response.body = body;
return response;
}
MockRestServer::Response MockError(int32_t code, const std::string& resource_type,
const std::string& resource_name, const std::string& message) {
ErrorResponse error(resource_type, resource_name, message, code);
return JsonResponse(code, error.ToJsonString().value());
}
std::string SnapshotJson(int64_t id) {
return fmt::format(
R"({{"version":3,"id":{},"schemaId":0,"baseManifestList":"bml","deltaManifestList":"dml",)"
R"("commitUser":"user1","commitIdentifier":1,"commitKind":"APPEND","timeMillis":100,)"
R"("totalRecordCount":10,"deltaRecordCount":1}})",
id);
}
std::string TableResponseJson(const std::string& name, const MockCatalogState::TableData& table) {
return fmt::format(
R"({{"id":"1","name":"{}","path":"{}","isExternal":false,"schemaId":{},"schema":{},)"
R"("owner":"owner1","updatedAt":123}})",
name, table.path, table.schema_id, table.schema_json);
}
// Serves `names` one item per page to exercise the pagination loop of the client.
std::pair<std::vector<std::string>, std::optional<std::string>> PageOf(
const std::vector<std::string>& names, const MockRestServer::Request& request) {
size_t index = 0;
auto token_iter = request.query_params.find(RestApi::kQueryParamPageToken);
if (token_iter != request.query_params.end()) {
// A malformed token must not throw: an exception on the accept thread would
// terminate the test binary.
index = static_cast<size_t>(
StringUtils::StringToValue<uint64_t>(token_iter->second).value_or(0));
}
std::vector<std::string> page;
std::optional<std::string> next_page_token;
if (index < names.size()) {
page.push_back(names[index]);
if (index + 1 < names.size()) {
next_page_token = std::to_string(index + 1);
}
}
return {page, next_page_token};
}
// Implements the subset of the rest catalog protocol used by `RestCatalog` on top of
// `MockCatalogState`.
MockRestServer::Response HandleCatalogRequest(MockCatalogState* state,
const MockRestServer::Request& request) {
std::lock_guard<std::mutex> lock(state->mutex);
state->last_headers = request.headers;
auto auth_iter = request.headers.find("authorization");
if (auth_iter == request.headers.end() ||
auth_iter->second != std::string("Bearer ") + kToken) {
return MockError(401, "", "", "invalid token");
}
if (request.path == "/v1/config") {
auto warehouse_iter = request.query_params.find("warehouse");
if (warehouse_iter == request.query_params.end() || warehouse_iter->second != kWarehouse) {
return MockError(400, "", "", "unexpected warehouse");
}
ConfigResponse config(
{{RestApi::kOptionUrlPrefix, kPrefix},
{"header.x-server-header", "from-config"},
{"table-default.write-only", "true"},
{"table-default.bucket", "8"}},
{{"server-override", "from-server"}, {"header.x-shared-header", "from-config"}});
return JsonResponse(200, config.ToJsonString().value());
}
if (state->force_error_code) {
return MockError(state->force_error_code.value(), "", "", "injected failure");
}
const std::string base = std::string("/v1/") + kPrefix;
if (request.path.rfind(base, 0) != 0) {
return MockError(404, "", "", "unknown path " + request.path);
}
std::string rest = request.path.substr(base.size());
if (rest == "/databases") {
if (request.method == "GET") {
std::vector<std::string> names;
for (const auto& [name, tables] : state->databases) {
names.push_back(name);
}
auto [page, next_page_token] = PageOf(names, request);
ListDatabasesResponse response(page, next_page_token);
return JsonResponse(200, response.ToJsonString().value());
}
if (request.method == "POST") {
CreateDatabaseRequest create_request("", {});
if (!RapidJsonUtil::FromJsonString(request.body, &create_request).ok()) {
return MockError(400, "", "", "bad create database request");
}
if (state->databases.count(create_request.GetName()) > 0) {
return MockError(409, ErrorResponse::kResourceTypeDatabase,
create_request.GetName(), "database already exists");
}
state->databases[create_request.GetName()] = {};
return JsonResponse(200, "");
}
return MockError(400, "", "", "unsupported method");
}
if (rest == "/tables/rename" && request.method == "POST") {
RenameTableRequest rename_request("", "", "", "");
if (!RapidJsonUtil::FromJsonString(request.body, &rename_request).ok()) {
return MockError(400, "", "", "bad rename table request");
}
auto db_iter = state->databases.find(rename_request.GetSourceDatabase());
if (db_iter == state->databases.end() ||
db_iter->second.count(rename_request.GetSourceTable()) == 0) {
return MockError(404, ErrorResponse::kResourceTypeTable,
rename_request.GetSourceTable(), "table not found");
}
auto& dest_tables = state->databases[rename_request.GetDestinationDatabase()];
if (dest_tables.count(rename_request.GetDestinationTable()) > 0) {
return MockError(409, ErrorResponse::kResourceTypeTable,
rename_request.GetDestinationTable(), "table already exists");
}
dest_tables[rename_request.GetDestinationTable()] =
db_iter->second[rename_request.GetSourceTable()];
db_iter->second.erase(rename_request.GetSourceTable());
return JsonResponse(200, "");
}
const std::string databases_prefix = "/databases/";
if (rest.rfind(databases_prefix, 0) != 0) {
return MockError(404, "", "", "unknown path " + request.path);
}
std::string remainder = rest.substr(databases_prefix.size());
size_t tables_pos = remainder.find("/tables");
if (tables_pos == std::string::npos) {
const std::string& db_name = remainder;
auto db_iter = state->databases.find(db_name);
if (request.method == "GET") {
if (db_iter == state->databases.end()) {
return MockError(404, ErrorResponse::kResourceTypeDatabase, db_name,
"database not found");
}
std::string body = fmt::format(
R"({{"id":"1","name":"{}","location":"{}/{}.db","options":{{"dbk":"dbv"}}}})",
db_name, kWarehouse, db_name);
return JsonResponse(200, body);
}
if (request.method == "DELETE") {
if (db_iter == state->databases.end()) {
return MockError(404, ErrorResponse::kResourceTypeDatabase, db_name,
"database not found");
}
state->databases.erase(db_iter);
return JsonResponse(200, "");
}
return MockError(400, "", "", "unsupported method");
}
std::string db_name = remainder.substr(0, tables_pos);
auto db_iter = state->databases.find(db_name);
if (db_iter == state->databases.end()) {
return MockError(404, ErrorResponse::kResourceTypeDatabase, db_name, "database not found");
}
auto& tables = db_iter->second;
std::string table_part = remainder.substr(tables_pos + std::strlen("/tables"));
if (table_part.empty()) {
if (request.method == "GET") {
std::vector<std::string> names;
for (const auto& [name, table] : tables) {
names.push_back(name);
}
auto [page, next_page_token] = PageOf(names, request);
ListTablesResponse response(page, next_page_token);
return JsonResponse(200, response.ToJsonString().value());
}
if (request.method == "POST") {
CreateTableRequest create_request("", "", "");
if (!RapidJsonUtil::FromJsonString(request.body, &create_request).ok()) {
return MockError(400, "", "", "bad create table request");
}
if (tables.count(create_request.GetTable()) > 0) {
return MockError(409, ErrorResponse::kResourceTypeTable, create_request.GetTable(),
"table already exists");
}
MockCatalogState::TableData table;
table.schema_json = create_request.GetSchemaJson();
table.schema_id = 0;
table.path = fmt::format("{}/{}.db/{}", kWarehouse, db_name, create_request.GetTable());
tables[create_request.GetTable()] = table;
return JsonResponse(200, "");
}
return MockError(400, "", "", "unsupported method");
}
// "/{table}" or "/{table}/snapshots"
std::string table_name = table_part.substr(1);
bool list_snapshots = false;
const std::string snapshots_suffix = "/snapshots";
if (table_name.size() > snapshots_suffix.size() &&
table_name.compare(table_name.size() - snapshots_suffix.size(), snapshots_suffix.size(),
snapshots_suffix) == 0) {
table_name = table_name.substr(0, table_name.size() - snapshots_suffix.size());
list_snapshots = true;
}
auto table_iter = tables.find(table_name);
if (table_iter == tables.end()) {
return MockError(404, ErrorResponse::kResourceTypeTable, table_name, "table not found");
}
if (list_snapshots) {
// two pages, out of order to exercise pagination and sorting
auto token_iter = request.query_params.find(RestApi::kQueryParamPageToken);
if (token_iter == request.query_params.end()) {
return JsonResponse(
200, fmt::format(R"({{"snapshots":[{}],"nextPageToken":"1"}})", SnapshotJson(2)));
}
return JsonResponse(200, fmt::format(R"({{"snapshots":[{}]}})", SnapshotJson(1)));
}
if (request.method == "GET") {
return JsonResponse(200, TableResponseJson(table_name, table_iter->second));
}
if (request.method == "DELETE") {
tables.erase(table_iter);
return JsonResponse(200, "");
}
return MockError(400, "", "", "unsupported method");
}
} // namespace
class RestCatalogTest : public ::testing::Test {
protected:
void SetUp() override {
state_ = std::make_shared<MockCatalogState>();
ASSERT_OK_AND_ASSIGN(
server_, MockRestServer::Start([state = state_](const MockRestServer::Request& req) {
return HandleCatalogRequest(state.get(), req);
}));
options_ = {
{CatalogOptions::METASTORE, "rest"},
{CatalogOptions::URI, server_->GetBaseUri()},
{CatalogOptions::TOKEN_PROVIDER, "bear"},
{CatalogOptions::TOKEN, kToken},
{Options::FILE_SYSTEM, "local"},
// mock_format is linked statically into the test binary, so its factory is
// registered in the binary's own registry even when the real format plugin
// dylibs register into a different one (macOS two-level namespace)
{Options::FILE_FORMAT, "mock_format"},
{Options::MANIFEST_FORMAT, "mock_format"},
{"header.x-client-header", "from-client"},
{"header.x-shared-header", "from-client"},
};
}
void TearDown() override {
if (server_) {
server_->Stop();
}
}
Result<std::unique_ptr<RestCatalog>> CreateRestCatalog() {
return RestCatalog::Create(kWarehouse, options_, nullptr);
}
Status CreateSampleTable(Catalog* catalog, const Identifier& identifier,
bool ignore_if_exists = false) {
std::shared_ptr<arrow::Schema> schema =
arrow::schema({arrow::field("f0", arrow::int32(), /*nullable=*/false),
arrow::field("f1", arrow::utf8())});
struct ArrowSchema c_schema;
if (!arrow::ExportSchema(*schema, &c_schema).ok()) {
return Status::Invalid("failed to export arrow schema");
}
Status status =
catalog->CreateTable(identifier, &c_schema, /*partition_keys=*/{},
/*primary_keys=*/{"f0"}, {{"bucket", "2"}}, ignore_if_exists);
// CreateTable takes ownership of the exported schema only once it reaches
// arrow::ImportSchema, which an identifier rejected by its checks never does
if (c_schema.release != nullptr) {
c_schema.release(&c_schema);
}
return status;
}
// Seeds `schema_json` as table `table_name` of "db1" behind the mock server and
// expects loading the table to fail with an Invalid status carrying
// `expected_message`.
void ExpectBrokenSchemaRejected(Catalog* catalog, const std::string& table_name,
const std::string& schema_json,
const std::string& expected_message) {
MockCatalogState::TableData table_data;
table_data.schema_json = schema_json;
table_data.path = "wh1/db1.db/" + table_name;
{
std::lock_guard<std::mutex> lock(state_->mutex);
state_->databases["db1"][table_name] = table_data;
}
Status status = catalog->GetTable(Identifier("db1", table_name)).status();
ASSERT_TRUE(status.IsInvalid()) << status.ToString();
ASSERT_NOK_WITH_MSG(status, expected_message);
}
std::shared_ptr<MockCatalogState> state_;
std::unique_ptr<MockRestServer> server_;
std::map<std::string, std::string> options_;
};
TEST_F(RestCatalogTest, CreateMergesServerConfig) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
const std::map<std::string, std::string>& merged = catalog->GetOptions();
ASSERT_EQ(kPrefix, merged.at(RestApi::kOptionUrlPrefix));
ASSERT_EQ("from-server", merged.at("server-override"));
ASSERT_EQ(kWarehouse, catalog->GetRootPath());
ASSERT_NE(nullptr, catalog->GetFileSystem());
}
TEST_F(RestCatalogTest, CatalogFactoryMetastoreDispatch) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<Catalog> catalog, Catalog::Create(kWarehouse, options_));
ASSERT_OK_AND_ASSIGN(std::vector<std::string> databases, catalog->ListDatabases());
ASSERT_TRUE(databases.empty());
options_[CatalogOptions::METASTORE] = "something-else";
Status status = Catalog::Create(kWarehouse, options_).status();
ASSERT_TRUE(status.IsInvalid()) << status.ToString();
ASSERT_NOK_WITH_MSG(status, "unsupported metastore");
}
TEST_F(RestCatalogTest, CreateWithWrongTokenFails) {
options_[CatalogOptions::TOKEN] = "wrong-token";
ASSERT_NOK_WITH_MSG(CreateRestCatalog().status(), "not authorized");
}
TEST_F(RestCatalogTest, CreateRejectsInvalidOptions) {
// all rejected by client side validation, before any request reaches the server
const std::map<std::string, std::string> valid_options = options_;
options_.erase(CatalogOptions::URI);
ASSERT_NOK_WITH_MSG(CreateRestCatalog().status(), "'uri' must be configured");
options_ = valid_options;
options_.erase(CatalogOptions::TOKEN_PROVIDER);
ASSERT_NOK_WITH_MSG(CreateRestCatalog().status(), "'token.provider' must be configured");
options_ = valid_options;
options_[CatalogOptions::TOKEN_PROVIDER] = "dlf";
Status unsupported_provider = CreateRestCatalog().status();
ASSERT_TRUE(unsupported_provider.IsNotImplemented()) << unsupported_provider.ToString();
ASSERT_NOK_WITH_MSG(unsupported_provider, "unsupported token provider");
options_ = valid_options;
options_.erase(CatalogOptions::TOKEN);
ASSERT_NOK_WITH_MSG(CreateRestCatalog().status(), "bear token provider");
}
TEST_F(RestCatalogTest, DatabaseOperations) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false));
ASSERT_OK(catalog->CreateDatabase("db2", {}, /*ignore_if_exists=*/false));
Status duplicated = catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false);
ASSERT_TRUE(duplicated.IsExist()) << duplicated.ToString();
ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/true));
// the mock server returns one database per page
ASSERT_OK_AND_ASSIGN(std::vector<std::string> databases, catalog->ListDatabases());
ASSERT_EQ((std::vector<std::string>{"db1", "db2"}), databases);
ASSERT_OK_AND_ASSIGN(bool exists, catalog->DatabaseExists("db1"));
ASSERT_TRUE(exists);
ASSERT_OK_AND_ASSIGN(exists, catalog->DatabaseExists("db3"));
ASSERT_FALSE(exists);
ASSERT_EQ("wh1/db1.db", catalog->GetDatabaseLocation("db1"));
ASSERT_EQ("", catalog->GetDatabaseLocation("db3"));
ASSERT_OK(catalog->DropDatabase("db2", /*ignore_if_not_exists=*/false, /*cascade=*/false));
ASSERT_OK(catalog->DropDatabase("db2", /*ignore_if_not_exists=*/true, /*cascade=*/false));
Status missing = catalog->DropDatabase("db2", /*ignore_if_not_exists=*/false,
/*cascade=*/false);
ASSERT_TRUE(missing.IsNotExist()) << missing.ToString();
ASSERT_OK(CreateSampleTable(catalog.get(), Identifier("db1", "t1")));
ASSERT_NOK_WITH_MSG(
catalog->DropDatabase("db1", /*ignore_if_not_exists=*/false, /*cascade=*/false),
"non-empty database");
// cascade drop skips the emptiness check
ASSERT_OK(catalog->DropDatabase("db1", /*ignore_if_not_exists=*/false, /*cascade=*/true));
ASSERT_OK_AND_ASSIGN(exists, catalog->DatabaseExists("db1"));
ASSERT_FALSE(exists);
}
TEST_F(RestCatalogTest, TableOperations) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false));
Identifier identifier("db1", "t1");
Status missing_db = CreateSampleTable(catalog.get(), Identifier("db_missing", "t1"));
ASSERT_TRUE(missing_db.IsNotExist()) << missing_db.ToString();
ASSERT_OK(CreateSampleTable(catalog.get(), identifier));
Status duplicated = CreateSampleTable(catalog.get(), identifier);
ASSERT_TRUE(duplicated.IsExist()) << duplicated.ToString();
ASSERT_OK(CreateSampleTable(catalog.get(), identifier, /*ignore_if_exists=*/true));
ASSERT_OK_AND_ASSIGN(std::vector<std::string> tables, catalog->ListTables("db1"));
ASSERT_EQ((std::vector<std::string>{"t1"}), tables);
Status list_missing = catalog->ListTables("db_missing").status();
ASSERT_TRUE(list_missing.IsNotExist()) << list_missing.ToString();
ASSERT_OK_AND_ASSIGN(bool exists, catalog->TableExists(identifier));
ASSERT_TRUE(exists);
ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("db1", "t2")));
ASSERT_FALSE(exists);
ASSERT_OK_AND_ASSIGN(std::string location, catalog->GetTableLocation(identifier));
ASSERT_EQ("wh1/db1.db/t1", location);
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> table, catalog->GetTable(identifier));
ASSERT_EQ("t1", table->Name());
std::shared_ptr<DataSchema> schema =
std::dynamic_pointer_cast<DataSchema>(table->LatestSchema());
ASSERT_NE(nullptr, schema);
ASSERT_EQ((std::vector<std::string>{"f0", "f1"}), schema->FieldNames());
ASSERT_EQ((std::vector<std::string>{"f0"}), schema->PrimaryKeys());
ASSERT_EQ(0, schema->Id());
// options are enriched with the table path and audit info from the server
ASSERT_EQ("wh1/db1.db/t1", schema->Options().at("path"));
ASSERT_EQ("owner1", schema->Options().at("owner"));
// "table-default." options of the merged config apply only where the caller left the
// option unset: "write-only" is taken from the config, "bucket" keeps the value passed
// to CreateTable instead of the configured "table-default.bucket" of 8
ASSERT_EQ("true", schema->Options().at("write-only"));
ASSERT_EQ("2", schema->Options().at("bucket"));
// timeMillis is backed by the server's audit "updatedAt" instead of the current
// time, keeping the conversion deterministic
std::shared_ptr<TableSchema> table_schema =
std::dynamic_pointer_cast<TableSchema>(table->LatestSchema());
ASSERT_NE(nullptr, table_schema);
ASSERT_EQ(123, table_schema->TimeMillis());
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Schema> loaded_schema,
catalog->LoadTableSchema(identifier));
ASSERT_EQ((std::vector<std::string>{"f0", "f1"}), loaded_schema->FieldNames());
Status schema_missing = catalog->LoadTableSchema(Identifier("db1", "t2")).status();
ASSERT_TRUE(schema_missing.IsNotExist()) << schema_missing.ToString();
// a schema comment of the server response is carried into the table schema
MockCatalogState::TableData commented;
commented.schema_json = R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)"
R"( "partitionKeys": [], "primaryKeys": [], "options": {},)"
R"( "comment": "a table comment"})";
commented.path = "wh1/db1.db/commented";
{
std::lock_guard<std::mutex> lock(state_->mutex);
state_->databases["db1"]["commented"] = commented;
}
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> commented_table,
catalog->GetTable(Identifier("db1", "commented")));
ASSERT_EQ("a table comment", commented_table->LatestSchema()->Comment().value_or(""));
ASSERT_OK(catalog->RenameTable(identifier, Identifier("db1", "t2"),
/*ignore_if_not_exists=*/false));
ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("db1", "t2")));
ASSERT_TRUE(exists);
ASSERT_OK(catalog->RenameTable(identifier, Identifier("db1", "t3"),
/*ignore_if_not_exists=*/true));
Status rename_missing = catalog->RenameTable(identifier, Identifier("db1", "t3"),
/*ignore_if_not_exists=*/false);
ASSERT_TRUE(rename_missing.IsNotExist()) << rename_missing.ToString();
ASSERT_OK(catalog->DropTable(Identifier("db1", "t2"), /*ignore_if_not_exists=*/false));
ASSERT_OK(catalog->DropTable(Identifier("db1", "t2"), /*ignore_if_not_exists=*/true));
Status drop_missing = catalog->DropTable(Identifier("db1", "t2"),
/*ignore_if_not_exists=*/false);
ASSERT_TRUE(drop_missing.IsNotExist()) << drop_missing.ToString();
}
TEST_F(RestCatalogTest, ClientAndServerHeadersAreSent) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
ASSERT_OK_AND_ASSIGN(std::vector<std::string> databases, catalog->ListDatabases());
// "header." options from both the client and the merged server config are sent as
// http headers on every request
{
std::lock_guard<std::mutex> lock(state_->mutex);
ASSERT_EQ("from-client", state_->last_headers.at("x-client-header"));
ASSERT_EQ("from-config", state_->last_headers.at("x-server-header"));
// when the client and the server config set the same "header." option, the
// merged config wins (overrides > client options > defaults)
ASSERT_EQ("from-config", state_->last_headers.at("x-shared-header"));
ASSERT_EQ(std::string("Bearer ") + kToken, state_->last_headers.at("authorization"));
}
// a request carrying a body declares the json content type (set before the auth
// headers are merged, so a signing auth provider covers it)
ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false));
{
std::lock_guard<std::mutex> lock(state_->mutex);
ASSERT_EQ("application/json", state_->last_headers.at("content-type"));
}
}
TEST_F(RestCatalogTest, SystemTableSchema) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false));
ASSERT_OK(CreateSampleTable(catalog.get(), Identifier("db1", "t1")));
// the "options" system table has a static schema and needs no file system access
Identifier system_identifier("db1", "t1$options");
ASSERT_OK_AND_ASSIGN(bool exists, catalog->TableExists(system_identifier));
ASSERT_TRUE(exists);
ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("db1", "t9$options")));
ASSERT_FALSE(exists);
ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("db1", "t1$unsupported")));
ASSERT_FALSE(exists);
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Schema> schema,
catalog->LoadTableSchema(system_identifier));
ASSERT_EQ((std::vector<std::string>{"key", "value"}), schema->FieldNames());
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> table, catalog->GetTable(system_identifier));
ASSERT_EQ("t1$options", table->Name());
ASSERT_EQ((std::vector<std::string>{"key", "value"}), table->LatestSchema()->FieldNames());
Status unsupported = catalog->LoadTableSchema(Identifier("db1", "t1$unsupported")).status();
ASSERT_TRUE(unsupported.IsNotExist()) << unsupported.ToString();
}
TEST_F(RestCatalogTest, BranchTableLoadsBranchSchemaFromServer) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false));
ASSERT_OK(CreateSampleTable(catalog.get(), Identifier("db1", "t1")));
// the identifier sent to the server keeps the branch, so the server resolves it and
// returns the branch's own schema. The path it reports is the data table root, not
// the branch subdirectory: readers derive "<path>/branch/branch-<name>" from the
// branch option, so a branch path here would be applied twice
MockCatalogState::TableData branch_data;
branch_data.schema_json = R"({"fields": [{"id": 0, "name": "b0", "type": "INT NOT NULL"}],)"
R"( "partitionKeys": [], "primaryKeys": [], "options": {}})";
branch_data.schema_id = 3;
branch_data.path = "wh1/db1.db/t1";
{
std::lock_guard<std::mutex> lock(state_->mutex);
state_->databases["db1"]["t1$branch_b1"] = branch_data;
}
Identifier branch_identifier("db1", "t1$branch_b1");
ASSERT_OK_AND_ASSIGN(bool exists, catalog->TableExists(branch_identifier));
ASSERT_TRUE(exists);
// a branch the server does not know is missing instead of silently falling back to
// the main table
ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("db1", "t1$branch_missing")));
ASSERT_FALSE(exists);
ASSERT_OK_AND_ASSIGN(std::string location, catalog->GetTableLocation(branch_identifier));
ASSERT_EQ("wh1/db1.db/t1", location);
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> table, catalog->GetTable(branch_identifier));
ASSERT_EQ("t1$branch_b1", table->Name());
std::shared_ptr<DataSchema> schema =
std::dynamic_pointer_cast<DataSchema>(table->LatestSchema());
ASSERT_NE(nullptr, schema);
// the branch's own schema and schema id, not the main table's
ASSERT_EQ((std::vector<std::string>{"b0"}), schema->FieldNames());
ASSERT_EQ(3, schema->Id());
ASSERT_EQ("b1", schema->Options().at(Options::BRANCH));
// the default branch is addressed as the bare table: "t1$branch_main" resolves to
// "t1" and carries no branch option
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> main_table,
catalog->GetTable(Identifier("db1", "t1$branch_main")));
std::shared_ptr<DataSchema> main_schema =
std::dynamic_pointer_cast<DataSchema>(main_table->LatestSchema());
ASSERT_NE(nullptr, main_schema);
ASSERT_EQ((std::vector<std::string>{"f0", "f1"}), main_schema->FieldNames());
ASSERT_EQ(0, main_schema->Options().count(Options::BRANCH));
// the default branch is matched ignoring case, as in the Java client, so
// "t1$branch_MAIN" addresses the bare table too
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> main_case_table,
catalog->GetTable(Identifier("db1", "t1$branch_MAIN")));
std::shared_ptr<DataSchema> main_case_schema =
std::dynamic_pointer_cast<DataSchema>(main_case_table->LatestSchema());
ASSERT_NE(nullptr, main_case_schema);
ASSERT_EQ((std::vector<std::string>{"f0", "f1"}), main_case_schema->FieldNames());
ASSERT_EQ(0, main_case_schema->Options().count(Options::BRANCH));
// a system table on a branch resolves against the branch's data table: the system
// suffix is stripped while the branch stays in the identifier sent to the server
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Schema> options_schema,
catalog->LoadTableSchema(Identifier("db1", "t1$branch_b1$options")));
ASSERT_EQ((std::vector<std::string>{"key", "value"}), options_schema->FieldNames());
// a missing branch fails through the system table path too instead of silently
// resolving against the main table
Status missing_branch =
catalog->LoadTableSchema(Identifier("db1", "t1$branch_missing$options")).status();
ASSERT_TRUE(missing_branch.IsNotExist()) << missing_branch.ToString();
ASSERT_NOK_WITH_MSG(catalog->DropTable(branch_identifier, /*ignore_if_not_exists=*/false),
"branch table");
ASSERT_NOK_WITH_MSG(catalog->RenameTable(branch_identifier, Identifier("db1", "t2"),
/*ignore_if_not_exists=*/false),
"branch table");
ASSERT_NOK_WITH_MSG(CreateSampleTable(catalog.get(), Identifier("db1", "t2$branch_b1")),
"branch table");
}
TEST_F(RestCatalogTest, NestedSchemaHighestFieldId) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false));
MockCatalogState::TableData table_data;
table_data.schema_json = R"({
"fields": [
{"id": 0, "name": "f0", "type": "INT NOT NULL"},
{"id": 1, "name": "s", "type": {"type": "ROW",
"fields": [{"id": 3, "name": "inner", "type": "INT"}]}},
{"id": 2, "name": "arr", "type": {"type": "ARRAY",
"element": {"type": "ROW",
"fields": [{"id": 7, "name": "deep", "type": "BIGINT"}]}}},
{"id": 4, "name": "m", "type": {"type": "MAP",
"key": {"type": "ROW NOT NULL",
"fields": [{"id": 8, "name": "k", "type": "INT NOT NULL"}]},
"value": {"type": "ROW",
"fields": [{"id": 9, "name": "v", "type": "INT"}]}}}
],
"partitionKeys": [],
"primaryKeys": [],
"options": {}
})";
table_data.schema_id = 5;
table_data.path = "wh1/db1.db/nested";
{
std::lock_guard<std::mutex> lock(state_->mutex);
state_->databases["db1"]["nested"] = table_data;
}
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> table,
catalog->GetTable(Identifier("db1", "nested")));
std::shared_ptr<DataSchema> schema =
std::dynamic_pointer_cast<DataSchema>(table->LatestSchema());
ASSERT_NE(nullptr, schema);
ASSERT_EQ(5, schema->Id());
// 9 lives inside the value row of the map: ROW, ARRAY element and MAP key/value
// must all be traversed
ASSERT_EQ(9, schema->HighestFieldId());
ASSERT_EQ((std::vector<std::string>{"f0", "s", "arr", "m"}), schema->FieldNames());
}
TEST_F(RestCatalogTest, ListTablesPaged) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false));
ASSERT_OK(CreateSampleTable(catalog.get(), Identifier("db1", "t1")));
ASSERT_OK(CreateSampleTable(catalog.get(), Identifier("db1", "t2")));
ASSERT_OK(CreateSampleTable(catalog.get(), Identifier("db1", "t3")));
ASSERT_OK_AND_ASSIGN(std::vector<std::string> tables, catalog->ListTables("db1"));
ASSERT_EQ((std::vector<std::string>{"t1", "t2", "t3"}), tables);
}
TEST_F(RestCatalogTest, BrokenSchemaRejected) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false));
ExpectBrokenSchemaRejected(catalog.get(), "duplicate",
R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"},)"
R"( {"id": 0, "name": "f1", "type": "STRING"}],)"
R"( "partitionKeys": [], "primaryKeys": [], "options": {}})",
"duplicated");
// an id inside a nested row colliding with an outer field id is a duplicate too
ExpectBrokenSchemaRejected(catalog.get(), "duplicate_nested",
R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"},)"
R"( {"id": 1, "name": "s", "type": {"type": "ROW",)"
R"( "fields": [{"id": 0, "name": "inner", "type": "INT"}]}}],)"
R"( "partitionKeys": [], "primaryKeys": [], "options": {}})",
"duplicated");
// a field without an integer id must fail the conversion instead of being
// silently skipped when computing highestFieldId
ExpectBrokenSchemaRejected(catalog.get(), "no_id",
R"({"fields": [{"name": "f0", "type": "INT NOT NULL"}],)"
R"( "partitionKeys": [], "primaryKeys": [], "options": {}})",
"integer id");
ExpectBrokenSchemaRejected(catalog.get(), "string_id",
R"({"fields": [{"id": "0", "name": "f0", "type": "INT NOT NULL"}],)"
R"( "partitionKeys": [], "primaryKeys": [], "options": {}})",
"integer id");
// a field that is not an object fails too instead of being silently skipped
ExpectBrokenSchemaRejected(catalog.get(), "non_object",
R"({"fields": [1],)"
R"( "partitionKeys": [], "primaryKeys": [], "options": {}})",
"must be an object");
// a missing or wrong-typed member is a visible failure instead of silently
// defaulting to an empty value (e.g. loading a partitioned table as
// unpartitioned)
ExpectBrokenSchemaRejected(catalog.get(), "missing_partition_keys",
R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)"
R"( "primaryKeys": [], "options": {}})",
"missing 'partitionKeys'");
ExpectBrokenSchemaRejected(catalog.get(), "missing_primary_keys",
R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)"
R"( "partitionKeys": [], "options": {}})",
"missing 'primaryKeys'");
ExpectBrokenSchemaRejected(catalog.get(), "missing_options",
R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)"
R"( "partitionKeys": [], "primaryKeys": []})",
"missing 'options'");
ExpectBrokenSchemaRejected(catalog.get(), "wrong_typed_partition_keys",
R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)"
R"( "partitionKeys": {}, "primaryKeys": [], "options": {}})",
"'partitionKeys' is not an array");
ExpectBrokenSchemaRejected(catalog.get(), "wrong_typed_primary_keys",
R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)"
R"( "partitionKeys": [], "primaryKeys": "f0", "options": {}})",
"'primaryKeys' is not an array");
ExpectBrokenSchemaRejected(catalog.get(), "wrong_typed_options",
R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)"
R"( "partitionKeys": [], "primaryKeys": [], "options": []})",
"'options' is not an object");
}
TEST_F(RestCatalogTest, PartitionKeysRoundTrip) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false));
std::shared_ptr<arrow::Schema> schema =
arrow::schema({arrow::field("f0", arrow::int32(), /*nullable=*/false),
arrow::field("f1", arrow::utf8(), /*nullable=*/false)});
struct ArrowSchema c_schema;
ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());
ASSERT_OK(catalog->CreateTable(Identifier("db1", "pt"), &c_schema,
/*partition_keys=*/{"f1"}, /*primary_keys=*/{}, {},
/*ignore_if_exists=*/false));
// the partition keys survive both the create request and the load response conversion
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> table, catalog->GetTable(Identifier("db1", "pt")));
std::shared_ptr<DataSchema> loaded =
std::dynamic_pointer_cast<DataSchema>(table->LatestSchema());
ASSERT_NE(nullptr, loaded);
ASSERT_EQ((std::vector<std::string>{"f1"}), loaded->PartitionKeys());
}
TEST_F(RestCatalogTest, ServerErrorIsPropagatedNotMappedToAbsent) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
{
std::lock_guard<std::mutex> lock(state_->mutex);
state_->force_error_code = 500;
}
// a server failure surfaces as an error instead of "does not exist"
Status db_status = catalog->DatabaseExists("db1").status();
ASSERT_NOK_WITH_MSG(db_status, "server error");
Status table_status = catalog->TableExists(Identifier("db1", "t1")).status();
ASSERT_NOK_WITH_MSG(table_status, "server error");
}
TEST_F(RestCatalogTest, SystemTableChecks) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
ASSERT_NOK_WITH_MSG(catalog->CreateDatabase("sys", {}, false), "system database");
ASSERT_NOK_WITH_MSG(catalog->DropDatabase("sys", false, false), "system database");
ASSERT_NOK_WITH_MSG(catalog->DropTable(Identifier("sys", "t"), false), "system table");
ASSERT_NOK_WITH_MSG(
catalog->RenameTable(Identifier("db1", "t1$snapshots"), Identifier("db1", "t2"), false),
"system table");
}
TEST_F(RestCatalogTest, SystemDatabase) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
// the "sys" database and its global system tables are resolved locally without
// contacting the server, like in FileSystemCatalog
ASSERT_OK_AND_ASSIGN(bool exists, catalog->DatabaseExists("sys"));
ASSERT_TRUE(exists);
ASSERT_OK_AND_ASSIGN(std::vector<std::string> sys_tables, catalog->ListTables("sys"));
ASSERT_TRUE(std::find(sys_tables.begin(), sys_tables.end(), "tables") != sys_tables.end());
ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("sys", "tables")));
ASSERT_TRUE(exists);
ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("sys", "unsupported")));
ASSERT_FALSE(exists);
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Schema> schema,
catalog->LoadTableSchema(Identifier("sys", "tables")));
ASSERT_FALSE(schema->FieldNames().empty());
Status missing = catalog->LoadTableSchema(Identifier("sys", "unsupported")).status();
ASSERT_TRUE(missing.IsNotExist()) << missing.ToString();
}
TEST_F(RestCatalogTest, ListSnapshots) {
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog());
ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false));
Identifier identifier("db1", "t1");
ASSERT_OK(CreateSampleTable(catalog.get(), identifier));
ASSERT_OK_AND_ASSIGN(std::vector<SnapshotInfo> snapshots,
catalog->ListSnapshots(identifier, ""));
ASSERT_EQ(2, snapshots.size());
// fetched via two pages and sorted by snapshot id
ASSERT_EQ(1, snapshots[0].snapshot_id);
ASSERT_EQ(2, snapshots[1].snapshot_id);
ASSERT_EQ("user1", snapshots[0].commit_user);
ASSERT_EQ(SnapshotInfo::CommitKind::APPEND, snapshots[0].commit_kind);
// the default branch is addressed as the bare table, so passing it explicitly
// equals the branch-less call
ASSERT_OK_AND_ASSIGN(std::vector<SnapshotInfo> main_snapshots,
catalog->ListSnapshots(identifier, "main"));
ASSERT_EQ(2, main_snapshots.size());
// the match ignores case, as in the Java client, so "MAIN" is the default branch too
ASSERT_OK_AND_ASSIGN(std::vector<SnapshotInfo> main_case_snapshots,
catalog->ListSnapshots(identifier, "MAIN"));
ASSERT_EQ(2, main_case_snapshots.size());
Status missing = catalog->ListSnapshots(Identifier("db1", "t9"), "").status();
ASSERT_TRUE(missing.IsNotExist()) << missing.ToString();
// a non-main branch is sent under its branch object name, so the server resolves
// the branch and lists its own snapshots
MockCatalogState::TableData branch_data;
branch_data.schema_json = R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)"
R"( "partitionKeys": [], "primaryKeys": [], "options": {}})";
branch_data.path = "wh1/db1.db/t1";
{
std::lock_guard<std::mutex> lock(state_->mutex);
state_->databases["db1"]["t1$branch_b1"] = branch_data;
}
ASSERT_OK_AND_ASSIGN(std::vector<SnapshotInfo> branch_snapshots,
catalog->ListSnapshots(identifier, "b1"));
ASSERT_EQ(2, branch_snapshots.size());
// a branch the server does not know is missing instead of silently falling back
// to the main table
Status missing_branch = catalog->ListSnapshots(identifier, "b_missing").status();
ASSERT_TRUE(missing_branch.IsNotExist()) << missing_branch.ToString();
// a branch must be passed as the branch argument, not encoded in the identifier
ASSERT_NOK_WITH_MSG(catalog->ListSnapshots(Identifier("db1", "t1$branch_b1"), "").status(),
"branch table");
}
TEST(RestApiErrorTest, ErrorToStatus) {
RestHttpClient::Response response;
response.code = 404;
response.body = R"({"message": "no table", "resourceType": "TABLE", "resourceName": "t1"})";
response.headers["x-request-id"] = "req-123";
Status status = RestApi::ErrorToStatus(response);
ASSERT_TRUE(status.IsNotExist());
ASSERT_TRUE(status.ToString().find("requestId:req-123") != std::string::npos)
<< status.ToString();
response.code = 409;
Status exist_status = RestApi::ErrorToStatus(response);
ASSERT_TRUE(exist_status.IsExist());
response.code = 501;
response.body = "";
ASSERT_TRUE(RestApi::ErrorToStatus(response).IsNotImplemented());
// a body that is not an error object at all is reported as such, since the body
// itself is never echoed
response.code = 500;
response.body = "not-a-json";
Status unparsable = RestApi::ErrorToStatus(response);
ASSERT_NOK_WITH_MSG(unparsable, "server error");
ASSERT_NOK_WITH_MSG(unparsable, "unparsable error response body (http status 500)");
ASSERT_EQ(std::string::npos, unparsable.ToString().find("not-a-json")) << unparsable.ToString();
// the code of the error body wins over the http status when they disagree (e.g. a
// gateway rewriting the status)
response.code = 500;
response.body = R"({"message": "gone", "code": 404})";
ASSERT_TRUE(RestApi::ErrorToStatus(response).IsNotExist());
// an error object without a message is told apart from an unparsable body, and the
// resource info is kept
response.code = 404;
response.body = R"({"resourceType": "TABLE", "resourceName": "t1"})";
Status empty_message = RestApi::ErrorToStatus(response);
ASSERT_NOK_WITH_MSG(empty_message, "empty error message (http status 404)");
ASSERT_TRUE(empty_message.ToString().find("resource name: t1") != std::string::npos)
<< empty_message.ToString();
// server messages that may embed secrets are redacted as a whole
response.code = 400;
response.body = R"({"message": "bad option password=abc123", "code": 400})";
Status redacted = RestApi::ErrorToStatus(response);
ASSERT_TRUE(redacted.IsInvalid()) << redacted.ToString();
ASSERT_TRUE(redacted.ToString().find("abc123") == std::string::npos) << redacted.ToString();
ASSERT_TRUE(redacted.ToString().find("******") != std::string::npos) << redacted.ToString();
// any header carrying a request id is used when x-request-id is absent
response.code = 404;
response.body = "";
response.headers.clear();
response.headers["x-amz-request-id"] = "amz-1";
Status fallback = RestApi::ErrorToStatus(response);
ASSERT_TRUE(fallback.ToString().find("requestId:amz-1") != std::string::npos)
<< fallback.ToString();
// the "unknown" placeholder is not a real request id
response.headers.clear();
response.headers["x-request-id"] = "unknown";
Status unknown_id = RestApi::ErrorToStatus(response);
ASSERT_TRUE(unknown_id.ToString().find("requestId") == std::string::npos)
<< unknown_id.ToString();
// 401/403 map to IOError; the mapped code is carried as a status detail so
// callers can distinguish them
response.headers.clear();
response.code = 401;
Status not_authorized = RestApi::ErrorToStatus(response);
ASSERT_NOK_WITH_MSG(not_authorized, "not authorized");
ASSERT_NE(nullptr, not_authorized.detail());
ASSERT_EQ(std::string(RestErrorDetail::kTypeId), not_authorized.detail()->type_id());
ASSERT_EQ(401, checked_pointer_cast<RestErrorDetail>(not_authorized.detail())->GetCode());
response.code = 403;
Status forbidden = RestApi::ErrorToStatus(response);
ASSERT_NOK_WITH_MSG(forbidden, "forbidden");
ASSERT_EQ(403, checked_pointer_cast<RestErrorDetail>(forbidden.detail())->GetCode());
// 503 and the codes without an own mapping (e.g. 429) become IOError with a
// message naming the code
response.code = 503;
ASSERT_NOK_WITH_MSG(RestApi::ErrorToStatus(response), "service unavailable");
response.code = 429;
ASSERT_NOK_WITH_MSG(RestApi::ErrorToStatus(response), "rest request failed with code 429");
response.code = 418;
ASSERT_NOK_WITH_MSG(RestApi::ErrorToStatus(response), "rest request failed with code 418");
}
TEST(RestApiErrorTest, MalformedSuccessBodyFails) {
// a 200 response whose body is not the expected json must fail, not crash or
// return partial data
ASSERT_OK_AND_ASSIGN(std::unique_ptr<MockRestServer> server,
MockRestServer::Start([](const MockRestServer::Request& request) {
MockRestServer::Response response;
response.body = "not-a-json";
return response;
}));
std::map<std::string, std::string> options = {
{CatalogOptions::URI, server->GetBaseUri()},
{CatalogOptions::TOKEN_PROVIDER, "bear"},
{CatalogOptions::TOKEN, kToken},
};
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestApi> api,
RestApi::Create(options, "", /*config_required=*/false));
Status list_status = api->ListDatabases().status();
ASSERT_NOK(list_status);
// the body is not echoed into the error: a successful response may contain
// credentials
ASSERT_EQ(std::string::npos, list_status.ToString().find("not-a-json"))
<< list_status.ToString();
ASSERT_NOK(api->GetTable(Identifier("db1", "t1")).status());
Status config_status = RestApi::Create(options, "", /*config_required=*/true).status();
ASSERT_NOK(config_status);
ASSERT_EQ(std::string::npos, config_status.ToString().find("not-a-json"))
<< config_status.ToString();
}
TEST(RestApiErrorTest, PagedListingStopsOnEmptyPageWithToken) {
// a server that keeps returning a page token with no data must not loop forever
std::atomic<int32_t> request_count{0};
ASSERT_OK_AND_ASSIGN(std::unique_ptr<MockRestServer> server,
MockRestServer::Start([&](const MockRestServer::Request& request) {
request_count++;
MockRestServer::Response response;
response.body = R"({"databases":[],"nextPageToken":"more"})";
return response;
}));
std::map<std::string, std::string> options = {
{CatalogOptions::URI, server->GetBaseUri()},
{CatalogOptions::TOKEN_PROVIDER, "bear"},
{CatalogOptions::TOKEN, kToken},
};
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestApi> api,
RestApi::Create(options, "", /*config_required=*/false));
ASSERT_OK_AND_ASSIGN(std::vector<std::string> databases, api->ListDatabases());
ASSERT_TRUE(databases.empty());
ASSERT_EQ(1, request_count.load());
}
} // namespace paimon::test