From 9f15a1010367a773bc0d10c0417d4105c4b6280f Mon Sep 17 00:00:00 2001 From: Li Feiyang Date: Tue, 4 Nov 2025 15:17:14 +0800 Subject: [PATCH 1/3] feat: add config, error and validation for rest types --- src/iceberg/catalog/rest/CMakeLists.txt | 2 +- src/iceberg/catalog/rest/json_internal.cc | 79 +++++++ src/iceberg/catalog/rest/json_internal.h | 6 + src/iceberg/catalog/rest/meson.build | 11 +- src/iceberg/catalog/rest/types.h | 21 +- src/iceberg/catalog/rest/validator.cc | 139 ++++++++++++ src/iceberg/catalog/rest/validator.h | 82 +++++++ src/iceberg/test/rest_json_internal_test.cc | 223 +++++++++++++++++++- 8 files changed, 558 insertions(+), 5 deletions(-) create mode 100644 src/iceberg/catalog/rest/validator.cc create mode 100644 src/iceberg/catalog/rest/validator.h diff --git a/src/iceberg/catalog/rest/CMakeLists.txt b/src/iceberg/catalog/rest/CMakeLists.txt index 38d897270..02440514e 100644 --- a/src/iceberg/catalog/rest/CMakeLists.txt +++ b/src/iceberg/catalog/rest/CMakeLists.txt @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -set(ICEBERG_REST_SOURCES rest_catalog.cc json_internal.cc) +set(ICEBERG_REST_SOURCES rest_catalog.cc json_internal.cc validator.cc) set(ICEBERG_REST_STATIC_BUILD_INTERFACE_LIBS) set(ICEBERG_REST_SHARED_BUILD_INTERFACE_LIBS) diff --git a/src/iceberg/catalog/rest/json_internal.cc b/src/iceberg/catalog/rest/json_internal.cc index 452de7a30..85f7e8ca8 100644 --- a/src/iceberg/catalog/rest/json_internal.cc +++ b/src/iceberg/catalog/rest/json_internal.cc @@ -27,6 +27,7 @@ #include #include "iceberg/catalog/rest/types.h" +#include "iceberg/catalog/rest/validator.h" #include "iceberg/json_internal.h" #include "iceberg/table_identifier.h" #include "iceberg/util/json_util_internal.h" @@ -59,9 +60,77 @@ constexpr std::string_view kDestination = "destination"; constexpr std::string_view kMetadata = "metadata"; constexpr std::string_view kConfig = "config"; constexpr std::string_view kIdentifiers = "identifiers"; +constexpr std::string_view kOverrides = "overrides"; +constexpr std::string_view kDefaults = "defaults"; +constexpr std::string_view kEndpoints = "endpoints"; +constexpr std::string_view kMessage = "message"; +constexpr std::string_view kType = "type"; +constexpr std::string_view kCode = "code"; +constexpr std::string_view kStack = "stack"; +constexpr std::string_view kError = "error"; } // namespace +nlohmann::json ToJson(const CatalogConfig& config) { + nlohmann::json json; + json[kOverrides] = config.overrides; + json[kDefaults] = config.defaults; + if (!config.endpoints.empty()) { + json[kEndpoints] = config.endpoints; + } + return json; +} + +Result CatalogConfigFromJson(const nlohmann::json& json) { + CatalogConfig config; + ICEBERG_ASSIGN_OR_RAISE( + config.overrides, + GetJsonValueOrDefault(json, kOverrides)); + ICEBERG_ASSIGN_OR_RAISE( + config.defaults, GetJsonValueOrDefault(json, kDefaults)); + ICEBERG_ASSIGN_OR_RAISE( + config.endpoints, + GetJsonValueOrDefault>(json, kEndpoints)); + ICEBERG_RETURN_UNEXPECTED(Validator::Validate(config)); + return config; +} + +nlohmann::json ToJson(const ErrorModel& error) { + nlohmann::json json; + json[kMessage] = error.message; + json[kType] = error.type; + json[kCode] = error.code; + if (!error.stack.empty()) { + json[kStack] = error.stack; + } + return json; +} + +Result ErrorModelFromJson(const nlohmann::json& json) { + ErrorModel error; + ICEBERG_ASSIGN_OR_RAISE(error.message, GetJsonValue(json, kMessage)); + ICEBERG_ASSIGN_OR_RAISE(error.type, GetJsonValue(json, kType)); + ICEBERG_ASSIGN_OR_RAISE(error.code, GetJsonValue(json, kCode)); + ICEBERG_ASSIGN_OR_RAISE(error.stack, + GetJsonValueOrDefault>(json, kStack)); + ICEBERG_RETURN_UNEXPECTED(Validator::Validate(error)); + return error; +} + +nlohmann::json ToJson(const ErrorResponse& response) { + nlohmann::json json; + json[kError] = ToJson(response.error); + return json; +} + +Result ErrorResponseFromJson(const nlohmann::json& json) { + ErrorResponse response; + ICEBERG_ASSIGN_OR_RAISE(auto error_json, GetJsonValue(json, kError)); + ICEBERG_ASSIGN_OR_RAISE(response.error, ErrorModelFromJson(error_json)); + ICEBERG_RETURN_UNEXPECTED(Validator::Validate(response)); + return response; +} + nlohmann::json ToJson(const CreateNamespaceRequest& request) { nlohmann::json json; json[kNamespace] = request.namespace_.levels; @@ -77,6 +146,7 @@ Result CreateNamespaceRequestFromJson( ICEBERG_ASSIGN_OR_RAISE( request.properties, GetJsonValueOrDefault(json, kProperties)); + ICEBERG_RETURN_UNEXPECTED(Validator::Validate(request)); return request; } @@ -94,6 +164,7 @@ Result UpdateNamespacePropertiesRequestFromJso request.removals, GetJsonValueOrDefault>(json, kRemovals)); ICEBERG_ASSIGN_OR_RAISE( request.updates, GetJsonValueOrDefault(json, kUpdates)); + ICEBERG_RETURN_UNEXPECTED(Validator::Validate(request)); return request; } @@ -114,6 +185,7 @@ Result RegisterTableRequestFromJson(const nlohmann::json& GetJsonValue(json, kMetadataLocation)); ICEBERG_ASSIGN_OR_RAISE(request.overwrite, GetJsonValueOrDefault(json, kOverwrite, false)); + ICEBERG_RETURN_UNEXPECTED(Validator::Validate(request)); return request; } @@ -131,6 +203,7 @@ Result RenameTableRequestFromJson(const nlohmann::json& json ICEBERG_ASSIGN_OR_RAISE(auto dest_json, GetJsonValue(json, kDestination)); ICEBERG_ASSIGN_OR_RAISE(request.destination, TableIdentifierFromJson(dest_json)); + ICEBERG_RETURN_UNEXPECTED(Validator::Validate(request)); return request; } @@ -177,6 +250,7 @@ Result ListNamespacesResponseFromJson( ICEBERG_ASSIGN_OR_RAISE(auto ns, NamespaceFromJson(ns_json)); response.namespaces.push_back(std::move(ns)); } + ICEBERG_RETURN_UNEXPECTED(Validator::Validate(response)); return response; } @@ -232,6 +306,7 @@ Result UpdateNamespacePropertiesResponseFromJ response.removed, GetJsonValueOrDefault>(json, kRemoved)); ICEBERG_ASSIGN_OR_RAISE( response.missing, GetJsonValueOrDefault>(json, kMissing)); + ICEBERG_RETURN_UNEXPECTED(Validator::Validate(response)); return response; } @@ -256,6 +331,7 @@ Result ListTablesResponseFromJson(const nlohmann::json& json ICEBERG_ASSIGN_OR_RAISE(auto identifier, TableIdentifierFromJson(id_json)); response.identifiers.push_back(std::move(identifier)); } + ICEBERG_RETURN_UNEXPECTED(Validator::Validate(response)); return response; } @@ -265,6 +341,9 @@ Result ListTablesResponseFromJson(const nlohmann::json& json return Model##FromJson(json); \ } +ICEBERG_DEFINE_FROM_JSON(CatalogConfig) +ICEBERG_DEFINE_FROM_JSON(ErrorModel) +ICEBERG_DEFINE_FROM_JSON(ErrorResponse) ICEBERG_DEFINE_FROM_JSON(ListNamespacesResponse) ICEBERG_DEFINE_FROM_JSON(CreateNamespaceRequest) ICEBERG_DEFINE_FROM_JSON(CreateNamespaceResponse) diff --git a/src/iceberg/catalog/rest/json_internal.h b/src/iceberg/catalog/rest/json_internal.h index 129e88393..986066f1a 100644 --- a/src/iceberg/catalog/rest/json_internal.h +++ b/src/iceberg/catalog/rest/json_internal.h @@ -25,6 +25,9 @@ #include "iceberg/catalog/rest/types.h" #include "iceberg/result.h" +/// \file iceberg/catalog/rest/json_internal.h +/// JSON serialization and deserialization for Iceberg REST Catalog API types. + namespace iceberg::rest { template @@ -40,6 +43,9 @@ Result FromJson(const nlohmann::json& json); /// \note Don't forget to add `ICEBERG_DEFINE_FROM_JSON` to the end of /// `json_internal.cc` to define the `FromJson` function for the model. +ICEBERG_DECLARE_JSON_SERDE(CatalogConfig) +ICEBERG_DECLARE_JSON_SERDE(ErrorModel) +ICEBERG_DECLARE_JSON_SERDE(ErrorResponse) ICEBERG_DECLARE_JSON_SERDE(ListNamespacesResponse) ICEBERG_DECLARE_JSON_SERDE(CreateNamespaceRequest) ICEBERG_DECLARE_JSON_SERDE(CreateNamespaceResponse) diff --git a/src/iceberg/catalog/rest/meson.build b/src/iceberg/catalog/rest/meson.build index 5f1f635ab..e8edc35c0 100644 --- a/src/iceberg/catalog/rest/meson.build +++ b/src/iceberg/catalog/rest/meson.build @@ -15,7 +15,11 @@ # specific language governing permissions and limitations # under the License. -iceberg_rest_sources = files('json_internal.cc', 'rest_catalog.cc') +iceberg_rest_sources = files( + 'json_internal.cc', + 'rest_catalog.cc', + 'validator.cc', +) # cpr does not export symbols, so on Windows it must # be used as a static lib cpr_needs_static = ( @@ -46,4 +50,7 @@ iceberg_rest_dep = declare_dependency( meson.override_dependency('iceberg-rest', iceberg_rest_dep) pkg.generate(iceberg_rest_lib) -install_headers(['rest_catalog.h', 'types.h'], subdir: 'iceberg/catalog/rest') +install_headers( + ['rest_catalog.h', 'types.h', 'json_internal.h', 'validator.h'], + subdir: 'iceberg/catalog/rest', +) diff --git a/src/iceberg/catalog/rest/types.h b/src/iceberg/catalog/rest/types.h index 11411cdb7..bc3a734fe 100644 --- a/src/iceberg/catalog/rest/types.h +++ b/src/iceberg/catalog/rest/types.h @@ -20,7 +20,6 @@ #pragma once #include -#include #include #include #include @@ -34,6 +33,26 @@ namespace iceberg::rest { +/// \brief Server-provided configuration for the catalog. +struct ICEBERG_REST_EXPORT CatalogConfig { + std::unordered_map overrides; // required + std::unordered_map defaults; // required + std::vector endpoints; +}; + +/// \brief JSON error payload returned in a response with further details on the error. +struct ICEBERG_REST_EXPORT ErrorModel { + std::string message; // required + std::string type; // required + uint16_t code; // required + std::vector stack; +}; + +/// \brief Error response body returned in a response. +struct ICEBERG_REST_EXPORT ErrorResponse { + ErrorModel error; // required +}; + /// \brief Request to create a namespace. struct ICEBERG_REST_EXPORT CreateNamespaceRequest { Namespace namespace_; // required diff --git a/src/iceberg/catalog/rest/validator.cc b/src/iceberg/catalog/rest/validator.cc new file mode 100644 index 000000000..5e8749f00 --- /dev/null +++ b/src/iceberg/catalog/rest/validator.cc @@ -0,0 +1,139 @@ +/* + * 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. + */ + +#include "iceberg/catalog/rest/validator.h" + +#include +#include +#include +#include + +#include "iceberg/catalog/rest/types.h" +#include "iceberg/result.h" + +namespace iceberg::rest { + +// Configuration and Error types + +Status Validator::Validate(const CatalogConfig& config) { + // TODO(Li Feiyang): Add an invalidEndpoint test that validates endpoint format. + // See: + // https://github.com/apache/iceberg/blob/main/core/src/test/java/org/apache/iceberg/rest/responses/TestConfigResponseParser.java#L164 + // for reference. + return {}; +} + +Status Validator::Validate(const ErrorModel& error) { + if (error.message.empty() || error.type.empty()) [[unlikely]] { + return Invalid("Invalid error model: missing required fields"); + } + + if (error.code < 400 || error.code > 600) [[unlikely]] { + return Invalid("Invalid error model: code must be between 400 and 600"); + } + + // stack is optional, no validation needed + return {}; +} + +Status Validator::Validate(const ErrorResponse& response) { return {}; } + +// Namespace operations + +Status Validator::Validate(const ListNamespacesResponse& response) { return {}; } + +Status Validator::Validate(const CreateNamespaceRequest& request) { return {}; } + +Status Validator::Validate(const CreateNamespaceResponse& response) { return {}; } + +Status Validator::Validate(const GetNamespaceResponse& response) { return {}; } + +Status Validator::Validate(const UpdateNamespacePropertiesRequest& request) { + // keys in updates and removals must not overlap + if (request.removals.empty() || request.updates.empty()) [[unlikely]] { + return {}; + } + + std::unordered_set remove_set(request.removals.begin(), + request.removals.end()); + std::vector common; + + for (const std::string& k : request.updates | std::views::keys) { + if (remove_set.contains(k)) { + common.push_back(k); + } + } + + if (!common.empty()) { + std::string keys; + bool first = true; + for (const std::string& s : common) { + if (!std::exchange(first, false)) keys += ", "; + keys += s; + } + + return Invalid( + "Invalid namespace properties update: cannot simultaneously set and remove keys: " + "[{}]", + keys); + } + return {}; +} + +Status Validator::Validate(const UpdateNamespacePropertiesResponse& response) { + return {}; +} + +// Table operations + +Status Validator::Validate(const ListTablesResponse& response) { return {}; } + +Status Validator::Validate(const LoadTableResult& result) { + if (!result.metadata) [[unlikely]] { + return Invalid("Invalid metadata: null"); + } + return {}; +} + +Status Validator::Validate(const RegisterTableRequest& request) { + if (request.name.empty()) [[unlikely]] { + return Invalid("Invalid table name: empty"); + } + + if (request.metadata_location.empty()) [[unlikely]] { + return Invalid("Invalid metadata location: empty"); + } + + return {}; +} + +Status Validator::Validate(const RenameTableRequest& request) { + if (request.source.ns.levels.empty() || request.source.name.empty()) [[unlikely]] { + return Invalid("Invalid source identifier"); + } + + if (request.destination.ns.levels.empty() || request.destination.name.empty()) + [[unlikely]] { + return Invalid("Invalid destination identifier"); + } + + return {}; +} + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/validator.h b/src/iceberg/catalog/rest/validator.h new file mode 100644 index 000000000..44c445eb0 --- /dev/null +++ b/src/iceberg/catalog/rest/validator.h @@ -0,0 +1,82 @@ +/* + * 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. + */ + +#pragma once + +#include "iceberg/catalog/rest/iceberg_rest_export.h" +#include "iceberg/catalog/rest/types.h" +#include "iceberg/result.h" + +/// \file iceberg/catalog/rest/validator.h +/// Validator for REST Catalog API types. + +namespace iceberg::rest { + +/// \brief Validator for REST Catalog API types. Validation should be called after +/// deserializing objects from external sources to ensure data integrity before the +/// objects are used. +class ICEBERG_REST_EXPORT Validator { + public: + // Configuration and Error types + + /// \brief Validates a CatalogConfig object. + static Status Validate(const CatalogConfig& config); + + /// \brief Validates an ErrorModel object. + static Status Validate(const ErrorModel& error); + + /// \brief Validates an ErrorResponse object. + static Status Validate(const ErrorResponse& response); + + // Namespace operations + + /// \brief Validates a ListNamespacesResponse object. + static Status Validate(const ListNamespacesResponse& response); + + /// \brief Validates a CreateNamespaceRequest object. + static Status Validate(const CreateNamespaceRequest& request); + + /// \brief Validates a CreateNamespaceResponse object. + static Status Validate(const CreateNamespaceResponse& response); + + /// \brief Validates a GetNamespaceResponse object. + static Status Validate(const GetNamespaceResponse& response); + + /// \brief Validates an UpdateNamespacePropertiesRequest object. + static Status Validate(const UpdateNamespacePropertiesRequest& request); + + /// \brief Validates an UpdateNamespacePropertiesResponse object. + static Status Validate(const UpdateNamespacePropertiesResponse& response); + + // Table operations + + /// \brief Validates a ListTablesResponse object. + static Status Validate(const ListTablesResponse& response); + + /// \brief Validates a LoadTableResult object. + static Status Validate(const LoadTableResult& result); + + /// \brief Validates a RegisterTableRequest object. + static Status Validate(const RegisterTableRequest& request); + + /// \brief Validates a RenameTableRequest object. + static Status Validate(const RenameTableRequest& request); +}; + +} // namespace iceberg::rest diff --git a/src/iceberg/test/rest_json_internal_test.cc b/src/iceberg/test/rest_json_internal_test.cc index d95f6a2c3..24be1bebf 100644 --- a/src/iceberg/test/rest_json_internal_test.cc +++ b/src/iceberg/test/rest_json_internal_test.cc @@ -17,7 +17,6 @@ * under the License. */ -#include #include #include #include @@ -92,6 +91,20 @@ bool operator==(const RenameTableRequest& lhs, const RenameTableRequest& rhs) { lhs.destination.name == rhs.destination.name; } +bool operator==(const CatalogConfig& lhs, const CatalogConfig& rhs) { + return lhs.overrides == rhs.overrides && lhs.defaults == rhs.defaults && + lhs.endpoints == rhs.endpoints; +} + +bool operator==(const ErrorModel& lhs, const ErrorModel& rhs) { + return lhs.message == rhs.message && lhs.type == rhs.type && lhs.code == rhs.code && + lhs.stack == rhs.stack; +} + +bool operator==(const ErrorResponse& lhs, const ErrorResponse& rhs) { + return lhs.error == rhs.error; +} + // Test parameter structure for roundtrip tests template struct JsonRoundTripParam { @@ -747,4 +760,212 @@ INSTANTIATE_TEST_SUITE_P( return info.param.test_name; }); +DECLARE_ROUNDTRIP_TEST(CatalogConfig) + +INSTANTIATE_TEST_SUITE_P( + CatalogConfigCases, CatalogConfigTest, + ::testing::Values( + // Full config with both defaults and overrides + CatalogConfigParam{ + .test_name = "FullConfig", + .expected_json_str = + R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":{"clients":"5"}})", + .model = {.overrides = {{"clients", "5"}}, + .defaults = {{"warehouse", "s3://bucket/warehouse"}}}}, + // Only defaults + CatalogConfigParam{ + .test_name = "OnlyDefaults", + .expected_json_str = + R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":{}})", + .model = {.defaults = {{"warehouse", "s3://bucket/warehouse"}}}}, + // Only overrides + CatalogConfigParam{ + .test_name = "OnlyOverrides", + .expected_json_str = R"({"defaults":{},"overrides":{"clients":"5"}})", + .model = {.overrides = {{"clients", "5"}}}}, + // Both empty + CatalogConfigParam{.test_name = "BothEmpty", + .expected_json_str = R"({"defaults":{},"overrides":{}})", + .model = {}}, + // With endpoints + CatalogConfigParam{ + .test_name = "WithEndpoints", + .expected_json_str = + R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":{"clients":"5"},"endpoints":["GET /v1/config","POST /v1/tables"]})", + .model = {.overrides = {{"clients", "5"}}, + .defaults = {{"warehouse", "s3://bucket/warehouse"}}, + .endpoints = {"GET /v1/config", "POST /v1/tables"}}}, + // Only endpoints + CatalogConfigParam{ + .test_name = "OnlyEndpoints", + .expected_json_str = + R"({"defaults":{},"overrides":{},"endpoints":["GET /v1/config"]})", + .model = {.endpoints = {"GET /v1/config"}}}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +DECLARE_DESERIALIZE_TEST(CatalogConfig) + +INSTANTIATE_TEST_SUITE_P( + CatalogConfigDeserializeCases, CatalogConfigDeserializeTest, + ::testing::Values( + // Missing overrides field + CatalogConfigDeserializeParam{ + .test_name = "MissingOverrides", + .json_str = R"({"defaults":{"warehouse":"s3://bucket/warehouse"}})", + .expected_model = {.defaults = {{"warehouse", "s3://bucket/warehouse"}}}}, + // Null overrides field + CatalogConfigDeserializeParam{ + .test_name = "NullOverrides", + .json_str = + R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":null})", + .expected_model = {.defaults = {{"warehouse", "s3://bucket/warehouse"}}}}, + // Missing defaults field + CatalogConfigDeserializeParam{ + .test_name = "MissingDefaults", + .json_str = R"({"overrides":{"clients":"5"}})", + .expected_model = {.overrides = {{"clients", "5"}}}}, + // Null defaults field + CatalogConfigDeserializeParam{ + .test_name = "NullDefaults", + .json_str = R"({"defaults":null,"overrides":{"clients":"5"}})", + .expected_model = {.overrides = {{"clients", "5"}}}}, + // Empty JSON object + CatalogConfigDeserializeParam{ + .test_name = "EmptyJson", .json_str = R"({})", .expected_model = {}}, + // Both fields null + CatalogConfigDeserializeParam{.test_name = "BothNull", + .json_str = R"({"defaults":null,"overrides":null})", + .expected_model = {}}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +DECLARE_INVALID_TEST(CatalogConfig) + +INSTANTIATE_TEST_SUITE_P( + CatalogConfigInvalidCases, CatalogConfigInvalidTest, + ::testing::Values( + // Defaults has wrong type (array instead of object) + CatalogConfigInvalidParam{ + .test_name = "WrongDefaultsType", + .invalid_json_str = + R"({"defaults":["warehouse","s3://bucket/warehouse"],"overrides":{"clients":"5"}})", + .expected_error_message = "type must be object, but is array"}, + // Overrides has wrong type (string instead of object) + CatalogConfigInvalidParam{ + .test_name = "WrongOverridesType", + .invalid_json_str = + R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":"clients"})", + .expected_error_message = "type must be object, but is string"}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +DECLARE_ROUNDTRIP_TEST(ErrorResponse) + +INSTANTIATE_TEST_SUITE_P( + ErrorResponseCases, ErrorResponseTest, + ::testing::Values( + // Error without stack trace + ErrorResponseParam{ + .test_name = "WithoutStack", + .expected_json_str = + R"({"error":{"message":"The given namespace does not exist","type":"NoSuchNamespaceException","code":404}})", + .model = {.error = {.message = "The given namespace does not exist", + .type = "NoSuchNamespaceException", + .code = 404}}}, + // Error with stack trace + ErrorResponseParam{ + .test_name = "WithStack", + .expected_json_str = + R"({"error":{"message":"The given namespace does not exist","type":"NoSuchNamespaceException","code":404,"stack":["a","b"]}})", + .model = {.error = {.message = "The given namespace does not exist", + .type = "NoSuchNamespaceException", + .code = 404, + .stack = {"a", "b"}}}}, + // Different error type + ErrorResponseParam{ + .test_name = "DifferentError", + .expected_json_str = + R"({"error":{"message":"Internal server error","type":"InternalServerError","code":500,"stack":["line1","line2","line3"]}})", + .model = {.error = {.message = "Internal server error", + .type = "InternalServerError", + .code = 500, + .stack = {"line1", "line2", "line3"}}}}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +DECLARE_DESERIALIZE_TEST(ErrorResponse) + +INSTANTIATE_TEST_SUITE_P( + ErrorResponseDeserializeCases, ErrorResponseDeserializeTest, + ::testing::Values( + // Stack field is null (should deserialize to empty vector) + ErrorResponseDeserializeParam{ + .test_name = "NullStack", + .json_str = + R"({"error":{"message":"The given namespace does not exist","type":"NoSuchNamespaceException","code":404,"stack":null}})", + .expected_model = {.error = {.message = "The given namespace does not exist", + .type = "NoSuchNamespaceException", + .code = 404}}}, + // Stack field is missing (should deserialize to empty vector) + ErrorResponseDeserializeParam{ + .test_name = "MissingStack", + .json_str = + R"({"error":{"message":"The given namespace does not exist","type":"NoSuchNamespaceException","code":404}})", + .expected_model = {.error = {.message = "The given namespace does not exist", + .type = "NoSuchNamespaceException", + .code = 404}}}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +DECLARE_INVALID_TEST(ErrorResponse) + +INSTANTIATE_TEST_SUITE_P( + ErrorResponseInvalidCases, ErrorResponseInvalidTest, + ::testing::Values( + // Missing error field + ErrorResponseInvalidParam{.test_name = "MissingError", + .invalid_json_str = R"({})", + .expected_error_message = "Missing 'error'"}, + // Null error field + ErrorResponseInvalidParam{.test_name = "NullError", + .invalid_json_str = R"({"error":null})", + .expected_error_message = "Missing 'error'"}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +// ErrorModel tests - testing the nested error model structure +DECLARE_INVALID_TEST(ErrorModel) + +INSTANTIATE_TEST_SUITE_P( + ErrorModelInvalidCases, ErrorModelInvalidTest, + ::testing::Values( + // Missing required type field + ErrorModelInvalidParam{ + .test_name = "MissingType", + .invalid_json_str = + R"({"message":"The given namespace does not exist","code":404})", + .expected_error_message = "Missing 'type'"}, + // Missing required code field + ErrorModelInvalidParam{ + .test_name = "MissingCode", + .invalid_json_str = + R"({"message":"The given namespace does not exist","type":"NoSuchNamespaceException"})", + .expected_error_message = "Missing 'code'"}, + // Wrong type for message field + ErrorModelInvalidParam{ + .test_name = "WrongMessageType", + .invalid_json_str = + R"({"message":123,"type":"NoSuchNamespaceException","code":404})", + .expected_error_message = "type must be string, but is number"}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + } // namespace iceberg::rest From 35e5eaa3f1d4ecb983e0362be2c154632541c157 Mon Sep 17 00:00:00 2001 From: Li Feiyang Date: Wed, 5 Nov 2025 10:37:14 +0800 Subject: [PATCH 2/3] update --- src/iceberg/catalog/rest/meson.build | 1 + src/iceberg/meson.build | 7 +++++++ src/iceberg/test/meson.build | 1 + src/iceberg/test/rest_json_internal_test.cc | 1 - 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/iceberg/catalog/rest/meson.build b/src/iceberg/catalog/rest/meson.build index e8edc35c0..a65f5cad7 100644 --- a/src/iceberg/catalog/rest/meson.build +++ b/src/iceberg/catalog/rest/meson.build @@ -33,6 +33,7 @@ iceberg_rest_lib = library( 'iceberg_rest', sources: iceberg_rest_sources, dependencies: iceberg_rest_build_deps, + cpp_args: iceberg_common_cpp_args, gnu_symbol_visibility: 'hidden', cpp_shared_args: ['-DICEBERG_REST_EXPORTING'], cpp_static_args: ['-DICEBERG_REST_STATIC'], diff --git a/src/iceberg/meson.build b/src/iceberg/meson.build index 1b24f85fb..826d22f65 100644 --- a/src/iceberg/meson.build +++ b/src/iceberg/meson.build @@ -15,6 +15,12 @@ # specific language governing permissions and limitations # under the License. +cpp = meson.get_compiler('cpp') +iceberg_common_cpp_args = [] +if cpp.get_id() == 'msvc' + iceberg_common_cpp_args += cpp.get_supported_arguments(['/bigobj']) +endif + conf_data = configuration_data() version = meson.project_version() components = version.split('.') @@ -121,6 +127,7 @@ iceberg_lib = library( dependencies: iceberg_deps, include_directories: iceberg_include_dir, install: true, + cpp_args: iceberg_common_cpp_args, gnu_symbol_visibility: 'inlineshidden', cpp_shared_args: ['-DICEBERG_EXPORTING'], cpp_static_args: ['-DICEBERG_STATIC'], diff --git a/src/iceberg/test/meson.build b/src/iceberg/test/meson.build index 22ed4bdde..e5bb7b7bf 100644 --- a/src/iceberg/test/meson.build +++ b/src/iceberg/test/meson.build @@ -104,6 +104,7 @@ foreach test_name, values : iceberg_tests 'dependencies', [], ), + cpp_args: iceberg_common_cpp_args, ) test(test_name, exc) endforeach diff --git a/src/iceberg/test/rest_json_internal_test.cc b/src/iceberg/test/rest_json_internal_test.cc index 24be1bebf..2ea0c021a 100644 --- a/src/iceberg/test/rest_json_internal_test.cc +++ b/src/iceberg/test/rest_json_internal_test.cc @@ -940,7 +940,6 @@ INSTANTIATE_TEST_SUITE_P( return info.param.test_name; }); -// ErrorModel tests - testing the nested error model structure DECLARE_INVALID_TEST(ErrorModel) INSTANTIATE_TEST_SUITE_P( From 9ff779614fe162157e8dd8d56512b99f9995375e Mon Sep 17 00:00:00 2001 From: Li Feiyang Date: Wed, 5 Nov 2025 14:24:27 +0800 Subject: [PATCH 3/3] first version of rest catalog --- src/iceberg/catalog/rest/CMakeLists.txt | 7 +- src/iceberg/catalog/rest/catalog.cc | 199 ++++++++++++++++++ src/iceberg/catalog/rest/catalog.h | 165 +++++++++++++++ src/iceberg/catalog/rest/config.cc | 142 +++++++++++++ src/iceberg/catalog/rest/config.h | 135 ++++++++++++ .../rest/{rest_catalog.cc => constant.h} | 33 ++- .../catalog/rest/http_client_interal.h | 96 +++++++++ .../catalog/rest/http_client_internal.cc | 94 +++++++++ src/iceberg/catalog/rest/meson.build | 15 +- src/iceberg/catalog/rest/rest_catalog.h | 41 ---- src/iceberg/catalog/rest/util.h | 83 ++++++++ src/iceberg/result.h | 26 ++- src/iceberg/test/rest_catalog_test.cc | 84 +------- 13 files changed, 975 insertions(+), 145 deletions(-) create mode 100644 src/iceberg/catalog/rest/catalog.cc create mode 100644 src/iceberg/catalog/rest/catalog.h create mode 100644 src/iceberg/catalog/rest/config.cc create mode 100644 src/iceberg/catalog/rest/config.h rename src/iceberg/catalog/rest/{rest_catalog.cc => constant.h} (51%) create mode 100644 src/iceberg/catalog/rest/http_client_interal.h create mode 100644 src/iceberg/catalog/rest/http_client_internal.cc delete mode 100644 src/iceberg/catalog/rest/rest_catalog.h create mode 100644 src/iceberg/catalog/rest/util.h diff --git a/src/iceberg/catalog/rest/CMakeLists.txt b/src/iceberg/catalog/rest/CMakeLists.txt index 02440514e..3fdcd64a6 100644 --- a/src/iceberg/catalog/rest/CMakeLists.txt +++ b/src/iceberg/catalog/rest/CMakeLists.txt @@ -15,7 +15,12 @@ # specific language governing permissions and limitations # under the License. -set(ICEBERG_REST_SOURCES rest_catalog.cc json_internal.cc validator.cc) +set(ICEBERG_REST_SOURCES + catalog.cc + json_internal.cc + validator.cc + config.cc + http_client_internal.cc) set(ICEBERG_REST_STATIC_BUILD_INTERFACE_LIBS) set(ICEBERG_REST_SHARED_BUILD_INTERFACE_LIBS) diff --git a/src/iceberg/catalog/rest/catalog.cc b/src/iceberg/catalog/rest/catalog.cc new file mode 100644 index 000000000..ccb966c68 --- /dev/null +++ b/src/iceberg/catalog/rest/catalog.cc @@ -0,0 +1,199 @@ +/* + * 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. + */ + +#include "iceberg/catalog/rest/catalog.h" + +#include +#include + +#include + +#include "iceberg/catalog/rest/config.h" +#include "iceberg/catalog/rest/http_client_interal.h" +#include "iceberg/catalog/rest/json_internal.h" +#include "iceberg/catalog/rest/types.h" +#include "iceberg/catalog/rest/util.h" +#include "iceberg/json_internal.h" +#include "iceberg/result.h" +#include "iceberg/table.h" +#include "iceberg/util/macros.h" + +namespace iceberg::rest { + +Result RestCatalog::Make(RestCatalogConfig config) { + // Validate that uri is not empty + if (config.uri.empty()) { + return InvalidArgument("Rest catalog configuration property 'uri' is required."); + } + ICEBERG_ASSIGN_OR_RAISE(auto tmp_client, HttpClient::Make(config)); + const std::string endpoint = config.GetConfigEndpoint(); + cpr::Parameters params; + if (config.warehouse.has_value()) { + params.Add({"warehouse", config.warehouse.value()}); + } + ICEBERG_ASSIGN_OR_RAISE(const auto& response, tmp_client->Get(endpoint, params)); + switch (response.status_code) { + case cpr::status::HTTP_OK: { + ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.text)); + ICEBERG_ASSIGN_OR_RAISE(auto server_config, CatalogConfigFromJson(json)); + // Merge server config into client config, server config overrides > client config + // properties > server config defaults + auto final_props = std::move(server_config.defaults); + for (const auto& kv : config.properties_) { + final_props.insert_or_assign(kv.first, kv.second); + } + + for (const auto& kv : server_config.overrides) { + final_props.insert_or_assign(kv.first, kv.second); + } + RestCatalogConfig final_config = { + .uri = config.uri, + .name = config.name, + .warehouse = config.warehouse, + .properties_ = std::move(final_props), + }; + ICEBERG_ASSIGN_OR_RAISE(auto client, HttpClient::Make(final_config)); + return RestCatalog(std::make_shared(std::move(final_config)), + std::move(client)); + }; + default: { + ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.text)); + ICEBERG_ASSIGN_OR_RAISE(auto list_response, ErrorResponseFromJson(json)); + return UnknownError("Error listing namespaces: {}", list_response.error.message); + } + } +} + +RestCatalog::RestCatalog(std::shared_ptr config, + std::unique_ptr client) + : config_(std::move(config)), client_(std::move(client)) {} + +std::string_view RestCatalog::name() const { + return config_->name.has_value() ? std::string_view(config_->name.value()) + : std::string_view(""); +} + +Result> RestCatalog::ListNamespaces(const Namespace& ns) const { + const std::string endpoint = config_->GetNamespacesEndpoint(); + std::vector result; + std::string next_token; + while (true) { + cpr::Parameters params; + if (!ns.levels.empty()) { + params.Add({"parent", EncodeNamespaceForUrl(ns)}); + } + if (!next_token.empty()) { + params.Add({"page_token", next_token}); + } + ICEBERG_ASSIGN_OR_RAISE(const auto& response, client_->Get(endpoint, params)); + switch (response.status_code) { + case cpr::status::HTTP_OK: { + ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.text)); + ICEBERG_ASSIGN_OR_RAISE(auto list_response, ListNamespacesResponseFromJson(json)); + result.insert(result.end(), list_response.namespaces.begin(), + list_response.namespaces.end()); + if (list_response.next_page_token.empty()) { + return result; + } + next_token = list_response.next_page_token; + continue; + } + case cpr::status::HTTP_NOT_FOUND: { + return NoSuchNamespace("Namespace not found"); + } + default: + ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.text)); + ICEBERG_ASSIGN_OR_RAISE(auto list_response, ErrorResponseFromJson(json)); + return UnknownError("Error listing namespaces: {}", list_response.error.message); + } + } +} + +Status RestCatalog::CreateNamespace( + const Namespace& ns, const std::unordered_map& properties) { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::GetNamespaceProperties( + const Namespace& ns) const { + return NotImplemented("Not implemented"); +} + +Status RestCatalog::DropNamespace(const Namespace& ns) { + return NotImplemented("Not implemented"); +} + +Result RestCatalog::NamespaceExists(const Namespace& ns) const { + return NotImplemented("Not implemented"); +} + +Status RestCatalog::UpdateNamespaceProperties( + const Namespace& ns, const std::unordered_map& updates, + const std::unordered_set& removals) { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::ListTables(const Namespace& ns) const { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::CreateTable( + const TableIdentifier& identifier, const Schema& schema, const PartitionSpec& spec, + const std::string& location, + const std::unordered_map& properties) { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::UpdateTable( + const TableIdentifier& identifier, + const std::vector>& requirements, + const std::vector>& updates) { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::StageCreateTable( + const TableIdentifier& identifier, const Schema& schema, const PartitionSpec& spec, + const std::string& location, + const std::unordered_map& properties) { + return NotImplemented("Not implemented"); +} + +Status RestCatalog::DropTable(const TableIdentifier& identifier, bool purge) { + return NotImplemented("Not implemented"); +} + +Result RestCatalog::TableExists(const TableIdentifier& identifier) const { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::LoadTable(const TableIdentifier& identifier) { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::RegisterTable( + const TableIdentifier& identifier, const std::string& metadata_file_location) { + return NotImplemented("Not implemented"); +} + +std::unique_ptr RestCatalog::BuildTable( + const TableIdentifier& identifier, const Schema& schema) const { + return nullptr; +} + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/catalog.h b/src/iceberg/catalog/rest/catalog.h new file mode 100644 index 000000000..313e51af6 --- /dev/null +++ b/src/iceberg/catalog/rest/catalog.h @@ -0,0 +1,165 @@ +/* + * 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. + */ + +#pragma once + +#include +#include + +#include + +#include "iceberg/catalog.h" +#include "iceberg/catalog/rest/config.h" +#include "iceberg/catalog/rest/http_client_interal.h" +#include "iceberg/catalog/rest/iceberg_rest_export.h" +#include "iceberg/catalog/rest/types.h" +#include "iceberg/result.h" + +/// \file iceberg/catalog/rest/catalog.h +/// RestCatalog implementation for Iceberg REST API. + +namespace iceberg::rest { + +class ICEBERG_REST_EXPORT RestCatalog : public Catalog { + public: + RestCatalog(const RestCatalog&) = delete; + RestCatalog& operator=(const RestCatalog&) = delete; + RestCatalog(RestCatalog&&) = default; + RestCatalog& operator=(RestCatalog&&) = default; + + /// \brief Create a RestCatalog instance + /// + /// \param config the configuration for the RestCatalog + /// \return a RestCatalog instance + static Result Make(RestCatalogConfig config); + + /// \brief Return the name for this catalog + std::string_view name() const override; + + /// \brief List child namespaces from the given namespace. + Result> ListNamespaces(const Namespace& ns) const override; + + /// \brief Create a namespace with associated properties. + Status CreateNamespace( + const Namespace& ns, + const std::unordered_map& properties) override; + + /// \brief Get metadata properties for a namespace. + Result> GetNamespaceProperties( + const Namespace& ns) const override; + + /// \brief Drop a namespace. + Status DropNamespace(const Namespace& ns) override; + + /// \brief Check whether the namespace exists. + Result NamespaceExists(const Namespace& ns) const override; + + /// \brief Update a namespace's properties by applying additions and removals. + Status UpdateNamespaceProperties( + const Namespace& ns, const std::unordered_map& updates, + const std::unordered_set& removals) override; + + /// \brief Return all the identifiers under this namespace + Result> ListTables(const Namespace& ns) const override; + + /// \brief Create a table + Result> CreateTable( + const TableIdentifier& identifier, const Schema& schema, const PartitionSpec& spec, + const std::string& location, + const std::unordered_map& properties) override; + + /// \brief Update a table + /// + /// \param identifier a table identifier + /// \param requirements a list of table requirements + /// \param updates a list of table updates + /// \return a Table instance or ErrorKind::kAlreadyExists if the table already exists + Result> UpdateTable( + const TableIdentifier& identifier, + const std::vector>& requirements, + const std::vector>& updates) override; + + /// \brief Start a transaction to create a table + /// + /// \param identifier a table identifier + /// \param schema a schema + /// \param spec a partition spec + /// \param location a location for the table; leave empty if unspecified + /// \param properties a string map of table properties + /// \return a Transaction to create the table or ErrorKind::kAlreadyExists if the + /// table already exists + Result> StageCreateTable( + const TableIdentifier& identifier, const Schema& schema, const PartitionSpec& spec, + const std::string& location, + const std::unordered_map& properties) override; + + /// \brief Check whether table exists + /// + /// \param identifier a table identifier + /// \return Result indicating table exists or not. + /// - On success, the table existence was successfully checked (actual + /// existence may be inferred elsewhere). + /// - On failure, contains error information. + Result TableExists(const TableIdentifier& identifier) const override; + + /// \brief Drop a table; optionally delete data and metadata files + /// + /// If purge is set to true the implementation should delete all data and metadata + /// files. + /// + /// \param identifier a table identifier + /// \param purge if true, delete all data and metadata files in the table + /// \return Status indicating the outcome of the operation. + /// - On success, the table was dropped (or did not exist). + /// - On failure, contains error information. + Status DropTable(const TableIdentifier& identifier, bool purge) override; + + /// \brief Load a table + /// + /// \param identifier a table identifier + /// \return instance of Table implementation referred to by identifier or + /// ErrorKind::kNoSuchTable if the table does not exist + Result> LoadTable(const TableIdentifier& identifier) override; + + /// \brief Register a table with the catalog if it does not exist + /// + /// \param identifier a table identifier + /// \param metadata_file_location the location of a metadata file + /// \return a Table instance or ErrorKind::kAlreadyExists if the table already exists + Result> RegisterTable( + const TableIdentifier& identifier, + const std::string& metadata_file_location) override; + + /// \brief A builder used to create valid tables or start create/replace transactions + /// + /// \param identifier a table identifier + /// \param schema a schema + /// \return the builder to create a table or start a create/replace transaction + std::unique_ptr BuildTable( + const TableIdentifier& identifier, const Schema& schema) const override; + + private: + RestCatalog(std::shared_ptr config, + std::unique_ptr client); + + std::shared_ptr config_; + std::unique_ptr client_; +}; + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/config.cc b/src/iceberg/catalog/rest/config.cc new file mode 100644 index 000000000..6e64c5eaf --- /dev/null +++ b/src/iceberg/catalog/rest/config.cc @@ -0,0 +1,142 @@ +/* + * 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. + */ + +#include "iceberg/catalog/rest/config.h" + +#include +#include + +#include "iceberg/catalog/rest/constant.h" + +namespace iceberg::rest { + +std::string RestCatalogConfig::GetConfigEndpoint() const { + return std::format("{}/{}/config", TrimTrailingSlash(uri), kPathV1); +} + +std::string RestCatalogConfig::GetOAuth2TokensEndpoint() const { + return std::format("{}/{}/oauth/tokens", TrimTrailingSlash(uri), kPathV1); +} + +std::string RestCatalogConfig::GetNamespacesEndpoint() const { + return std::format("{}/{}/namespaces", TrimTrailingSlash(uri), kPathV1); +} + +std::string RestCatalogConfig::GetNamespaceEndpoint(const Namespace& ns) const { + return std::format("{}/{}/namespaces/{}", TrimTrailingSlash(uri), kPathV1, + EncodeNamespaceForUrl(ns)); +} + +std::string RestCatalogConfig::GetNamespacePropertiesEndpoint(const Namespace& ns) const { + return std::format("{}/{}/namespaces/{}/properties", TrimTrailingSlash(uri), kPathV1, + EncodeNamespaceForUrl(ns)); +} + +std::string RestCatalogConfig::GetTablesEndpoint(const Namespace& ns) const { + return std::format("{}/{}/namespaces/{}/tables", TrimTrailingSlash(uri), kPathV1, + EncodeNamespaceForUrl(ns)); +} + +std::string RestCatalogConfig::GetTableScanPlanEndpoint( + const TableIdentifier& table) const { + return std::format("{}/{}/namespaces/{}/tables/{}/plan", TrimTrailingSlash(uri), + kPathV1, EncodeNamespaceForUrl(table.ns), table.name); +} + +std::string RestCatalogConfig::GetTableScanPlanResultEndpoint( + const TableIdentifier& table, const std::string& plan_id) const { + return std::format("{}/{}/namespaces/{}/tables/{}/plan/{}", TrimTrailingSlash(uri), + kPathV1, EncodeNamespaceForUrl(table.ns), table.name, plan_id); +} + +std::string RestCatalogConfig::GetTableTasksEndpoint(const TableIdentifier& table) const { + return std::format("{}/{}/namespaces/{}/tables/{}/tasks", TrimTrailingSlash(uri), + kPathV1, EncodeNamespaceForUrl(table.ns), table.name); +} + +std::string RestCatalogConfig::GetRegisterTableEndpoint(const Namespace& ns) const { + return std::format("{}/{}/namespaces/{}/register", TrimTrailingSlash(uri), kPathV1, + EncodeNamespaceForUrl(ns)); +} + +std::string RestCatalogConfig::GetTableEndpoint(const TableIdentifier& table) const { + return std::format("{}/{}/namespaces/{}/tables/{}", TrimTrailingSlash(uri), kPathV1, + EncodeNamespaceForUrl(table.ns), table.name); +} + +std::string RestCatalogConfig::GetTableCredentialsEndpoint( + const TableIdentifier& table) const { + return std::format("{}/{}/namespaces/{}/tables/{}/credentials", TrimTrailingSlash(uri), + kPathV1, EncodeNamespaceForUrl(table.ns), table.name); +} + +std::string RestCatalogConfig::GetRenameTableEndpoint() const { + return std::format("{}/{}/tables/rename", TrimTrailingSlash(uri), kPathV1); +} + +std::string RestCatalogConfig::GetTableMetricsEndpoint( + const TableIdentifier& table) const { + return std::format("{}/{}/namespaces/{}/tables/{}/metrics", TrimTrailingSlash(uri), + kPathV1, EncodeNamespaceForUrl(table.ns), table.name); +} + +std::string RestCatalogConfig::GetTransactionCommitEndpoint() const { + return std::format("{}/{}/transactions/commit", TrimTrailingSlash(uri), kPathV1); +} + +std::string RestCatalogConfig::GetViewsEndpoint(const Namespace& ns) const { + return std::format("{}/{}/namespaces/{}/views", TrimTrailingSlash(uri), kPathV1, + EncodeNamespaceForUrl(ns)); +} + +std::string RestCatalogConfig::GetViewEndpoint(const TableIdentifier& view) const { + return std::format("{}/{}/namespaces/{}/views/{}", TrimTrailingSlash(uri), kPathV1, + EncodeNamespaceForUrl(view.ns), view.name); +} + +std::string RestCatalogConfig::GetRenameViewEndpoint() const { + return std::format("{}/{}/views/rename", TrimTrailingSlash(uri), kPathV1); +} + +Result RestCatalogConfig::GetExtraHeaders() const { + cpr::Header headers; + + headers[std::string(kHeaderContentType)] = std::string(kMimeTypeApplicationJson); + headers[std::string(kHeaderUserAgent)] = std::string(kUserAgent); + headers[std::string(kHeaderXClientVersion)] = std::string(kClientVersion); + + constexpr std::string_view prefix = "header."; + for (const auto& [key, value] : properties_) { + if (key.starts_with(prefix)) { + std::string_view header_name = std::string_view(key).substr(prefix.length()); + + if (header_name.empty()) { + return InvalidArgument("Header name cannot be empty after '{}' prefix", prefix); + } + + if (value.empty()) { + return InvalidArgument("Header value for '{}' cannot be empty", header_name); + } + headers[std::string(header_name)] = value; + } + } + return headers; +} + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/config.h b/src/iceberg/catalog/rest/config.h new file mode 100644 index 000000000..86d22cdfc --- /dev/null +++ b/src/iceberg/catalog/rest/config.h @@ -0,0 +1,135 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include + +#include "cpr/cpr.h" +#include "iceberg/catalog/rest/iceberg_rest_export.h" +#include "iceberg/catalog/rest/util.h" +#include "iceberg/result.h" +#include "iceberg/table_identifier.h" + +/// \file iceberg/catalog/rest/config.h +/// \brief RestCatalogConfig implementation for Iceberg REST API. + +namespace iceberg::rest { + +/// \brief Configuration struct for a Rest Catalog. +/// +/// This struct holds all the necessary configuration for connecting and interacting with +/// a Rest service. It's a simple data structure with public members that can be +/// initialized directly via aggregate initialization. It provides helper methods to +/// construct specific endpoint URLs and HTTP headers from the configuration properties. +struct ICEBERG_REST_EXPORT RestCatalogConfig { + /// \brief Returns the endpoint string for listing all catalog configuration settings. + std::string GetConfigEndpoint() const; + + /// \brief Returns the endpoint string for OAuth2 token operations (DEPRECATED). + std::string GetOAuth2TokensEndpoint() const; + + /// \brief Returns the endpoint string for listing or creating namespaces. + std::string GetNamespacesEndpoint() const; + + /// \brief Returns the endpoint string for loading or managing a specific namespace. + /// \param ns The namespace to get the endpoint for. + std::string GetNamespaceEndpoint(const Namespace& ns) const; + + /// \brief Returns the endpoint string for setting or removing namespace properties. + /// \param ns The namespace to get the properties endpoint for. + std::string GetNamespacePropertiesEndpoint(const Namespace& ns) const; + + /// \brief Returns the endpoint string for listing or creating tables in a namespace. + /// \param ns The namespace to get the tables endpoint for. + std::string GetTablesEndpoint(const Namespace& ns) const; + + /// \brief Returns the endpoint string for submitting a table scan for planning. + /// \param table The table identifier to get the scan plan endpoint for. + std::string GetTableScanPlanEndpoint(const TableIdentifier& table) const; + + /// \brief Returns the endpoint string for fetching scan planning results. + /// \param table The table identifier to get the scan plan result endpoint for. + /// \param plan_id The plan ID to fetch results for. + std::string GetTableScanPlanResultEndpoint(const TableIdentifier& table, + const std::string& plan_id) const; + + /// \brief Returns the endpoint string for fetching execution tasks for a plan. + /// \param table The table identifier to get the tasks endpoint for. + std::string GetTableTasksEndpoint(const TableIdentifier& table) const; + + /// \brief Returns the endpoint string for registering a table using metadata file + /// location. + /// \param ns The namespace to register the table in. + std::string GetRegisterTableEndpoint(const Namespace& ns) const; + + /// \brief Returns the endpoint string for loading, committing, or dropping a table. + /// \param table The table identifier to get the endpoint for. + std::string GetTableEndpoint(const TableIdentifier& table) const; + + /// \brief Returns the endpoint string for loading vended credentials for a table. + /// \param table The table identifier to get the credentials endpoint for. + std::string GetTableCredentialsEndpoint(const TableIdentifier& table) const; + + /// \brief Returns the endpoint string for renaming a table. + std::string GetRenameTableEndpoint() const; + + /// \brief Returns the endpoint string for submitting a metrics report for a table. + /// \param table The table identifier to get the metrics endpoint for. + std::string GetTableMetricsEndpoint(const TableIdentifier& table) const; + + /// \brief Returns the endpoint string for atomic multi-table commit operations. + std::string GetTransactionCommitEndpoint() const; + + /// \brief Returns the endpoint string for listing or creating views in a namespace. + /// \param ns The namespace to get the views endpoint for. + std::string GetViewsEndpoint(const Namespace& ns) const; + + /// \brief Returns the endpoint string for loading, replacing, or dropping a view. + /// \param view The view identifier to get the endpoint for. + std::string GetViewEndpoint(const TableIdentifier& view) const; + + /// \brief Returns the endpoint string for renaming a view. + std::string GetRenameViewEndpoint() const; + + /// \brief Generates extra HTTP headers to be added to every request from the + /// configuration. + /// + /// This includes default headers like Content-Type, User-Agent, X-Client-Version and + /// any custom headers prefixed with "header." in the properties. + /// \return A Result containing cpr::Header object, or an error if names/values are + /// invalid. + Result GetExtraHeaders() const; + + /// \brief The catalog's URI (required). + std::string uri; + + /// \brief The logical name of the catalog (optional). + std::optional name; + + /// \brief The warehouse location associated with the catalog (optional). + std::optional warehouse; + + /// \brief A string-to-string map of properties to store all other configurations. + std::unordered_map properties_; +}; + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/rest_catalog.cc b/src/iceberg/catalog/rest/constant.h similarity index 51% rename from src/iceberg/catalog/rest/rest_catalog.cc rename to src/iceberg/catalog/rest/constant.h index cd008e9b2..0248b6a26 100644 --- a/src/iceberg/catalog/rest/rest_catalog.cc +++ b/src/iceberg/catalog/rest/constant.h @@ -17,28 +17,27 @@ * under the License. */ -#include "iceberg/catalog/rest/rest_catalog.h" +#pragma once -#include +#include -#include +#include "iceberg/version.h" -#include "iceberg/catalog/rest/types.h" +/// \file iceberg/catalog/rest/constant.h +/// Constant values for Iceberg REST API. -namespace iceberg::catalog::rest { +namespace iceberg::rest { -RestCatalog::RestCatalog(const std::string& base_url) : base_url_(std::move(base_url)) {} +constexpr std::string_view kHeaderContentType = "Content-Type"; +constexpr std::string_view kHeaderAccept = "Accept"; +constexpr std::string_view kHeaderXClientVersion = "X-Client-Version"; +constexpr std::string_view kHeaderUserAgent = "User-Agent"; -cpr::Response RestCatalog::GetConfig() { - cpr::Url url = cpr::Url{base_url_ + "/v1/config"}; - cpr::Response r = cpr::Get(url); - return r; -} +constexpr std::string_view kMimeTypeApplicationJson = "application/json"; +constexpr std::string_view kClientVersion = "0.14.1"; +constexpr std::string_view kUserAgentPrefix = "iceberg-cpp/"; +constexpr std::string_view kUserAgent = "iceberg-cpp/" ICEBERG_VERSION_STRING; -cpr::Response RestCatalog::ListNamespaces() { - cpr::Url url = cpr::Url{base_url_ + "/v1/namespaces"}; - cpr::Response r = cpr::Get(url); - return r; -} +constexpr std::string_view kPathV1 = "v1"; -} // namespace iceberg::catalog::rest +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/http_client_interal.h b/src/iceberg/catalog/rest/http_client_interal.h new file mode 100644 index 000000000..6fc043736 --- /dev/null +++ b/src/iceberg/catalog/rest/http_client_interal.h @@ -0,0 +1,96 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include + +#include +#include + +#include "iceberg/catalog/rest/config.h" +#include "iceberg/catalog/rest/iceberg_rest_export.h" +#include "iceberg/result.h" + +/// \file iceberg/catalog/rest/http_client.h +/// \brief Http client for Iceberg REST API. + +namespace iceberg::rest { + +/// \brief HTTP client for making requests to Iceberg REST Catalog API. +/// +/// This class wraps the CPR library and provides a type-safe interface for making +/// HTTP requests. It handles authentication, headers, and response parsing. +class ICEBERG_REST_EXPORT HttpClient { + public: + /// \brief Factory function to create and initialize an HttpClient. + /// This is the preferred way to construct an HttpClient, as it can handle + /// potential errors during configuration parsing (e.g., invalid headers). + /// \param config The catalog configuration. + /// \return A Result containing a unique_ptr to the HttpClient, or an Error. + static Result> Make(RestCatalogConfig config); + + HttpClient(const HttpClient&) = delete; + HttpClient& operator=(const HttpClient&) = delete; + HttpClient(HttpClient&&) = default; + HttpClient& operator=(HttpClient&&) = default; + + /// \brief Sends a GET request. + /// \param target The target path relative to the base URL (e.g., "/v1/namespaces"). + Result Get(const std::string& target, const cpr::Parameters& params = {}, + const cpr::Header& headers = {}); + + /// \brief Sends a POST request. + /// \param target The target path relative to the base URL (e.g., "/v1/namespaces"). + Result Post(const std::string& target, const cpr::Body& body, + const cpr::Parameters& params = {}, + const cpr::Header& headers = {}); + + /// \brief Sends a HEAD request. + /// \param target The target path relative to the base URL (e.g., "/v1/namespaces"). + Result Head(const std::string& target, + const cpr::Parameters& params = {}, + const cpr::Header& headers = {}); + + /// \brief Sends a DELETE request. + /// \param target The target path relative to the base URL (e.g., "/v + Result Delete(const std::string& target, + const cpr::Parameters& params = {}, + const cpr::Header& headers = {}); + + private: + /// \brief Private constructor. Use the static Create() factory function instead. + HttpClient(std::shared_ptr config, + cpr::Header session_headers); + + /// \brief Internal helper to execute a request. + template + Result Execute(const std::string& target, const cpr::Parameters& params, + const cpr::Header& request_headers, + Func&& perform_request); + + std::shared_ptr config; + cpr::Url base_url; + cpr::Header default_headers; + std::unique_ptr session; +}; + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/http_client_internal.cc b/src/iceberg/catalog/rest/http_client_internal.cc new file mode 100644 index 000000000..9f9a49a09 --- /dev/null +++ b/src/iceberg/catalog/rest/http_client_internal.cc @@ -0,0 +1,94 @@ +/* + * 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. + */ + +#include + +#include "cpr/body.h" +#include "iceberg/catalog/rest/config.h" +#include "iceberg/catalog/rest/http_client_interal.h" +#include "iceberg/util/macros.h" + +namespace iceberg::rest { + +Result> HttpClient::Make(RestCatalogConfig config) { + if (config.uri.empty()) { + return InvalidArgument("HttpClient::Make received a config with empty URI"); + } + + ICEBERG_ASSIGN_OR_RAISE(auto session_headers, config.GetExtraHeaders()); + + auto config_ptr = std::make_shared(std::move(config)); + return std::unique_ptr( + new HttpClient(config_ptr, std::move(session_headers))); +} + +HttpClient::HttpClient(std::shared_ptr config, + cpr::Header session_headers) + : config(std::move(config)), + base_url(config->uri), + default_headers(std::move(session_headers)), + session(std::make_unique()) {} + +Result HttpClient::Get(const std::string& target, + const cpr::Parameters& params, + const cpr::Header& headers) { + return Execute(target, params, headers, + [&](cpr::Session& session) { return session.Get(); }); +} + +Result HttpClient::Post(const std::string& target, const cpr::Body& body, + const cpr::Parameters& params, + const cpr::Header& headers) { + return Execute(target, params, headers, [&](cpr::Session& session) { + session.SetBody(body); + return session.Post(); + }); +} + +Result HttpClient::Head(const std::string& target, + const cpr::Parameters& params, + const cpr::Header& headers) { + return Execute(target, params, headers, + [&](cpr::Session& session) { return session.Head(); }); +} + +Result HttpClient::Delete(const std::string& target, + const cpr::Parameters& params, + const cpr::Header& headers) { + return Execute(target, params, headers, + [&](cpr::Session& session) { return session.Delete(); }); +} + +template +Result HttpClient::Execute(const std::string& target, + const cpr::Parameters& params, + const cpr::Header& request_headers, + Func&& perform_request) { + cpr::Header combined_headers = default_headers; + combined_headers.insert(request_headers.begin(), request_headers.end()); + + session->SetUrl(base_url + target); + session->SetParameters(params); + session->SetHeader(combined_headers); + + cpr::Response response = perform_request(*session); + return response; +} + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/meson.build b/src/iceberg/catalog/rest/meson.build index a65f5cad7..75ee37e15 100644 --- a/src/iceberg/catalog/rest/meson.build +++ b/src/iceberg/catalog/rest/meson.build @@ -16,8 +16,10 @@ # under the License. iceberg_rest_sources = files( + 'catalog.cc', + 'config.cc', + 'http_client_internal.cc', 'json_internal.cc', - 'rest_catalog.cc', 'validator.cc', ) # cpr does not export symbols, so on Windows it must @@ -52,6 +54,15 @@ meson.override_dependency('iceberg-rest', iceberg_rest_dep) pkg.generate(iceberg_rest_lib) install_headers( - ['rest_catalog.h', 'types.h', 'json_internal.h', 'validator.h'], + [ + 'catalog.h', + 'config.h', + 'constant.h', + 'http_client_interal.h', + 'json_internal.h', + 'types.h', + 'util.h', + 'validator.h', + ], subdir: 'iceberg/catalog/rest', ) diff --git a/src/iceberg/catalog/rest/rest_catalog.h b/src/iceberg/catalog/rest/rest_catalog.h deleted file mode 100644 index 7b3e205c1..000000000 --- a/src/iceberg/catalog/rest/rest_catalog.h +++ /dev/null @@ -1,41 +0,0 @@ -// 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. - -#pragma once - -#include - -#include - -#include "iceberg/catalog/rest/iceberg_rest_export.h" - -namespace iceberg::catalog::rest { - -class ICEBERG_REST_EXPORT RestCatalog { - public: - explicit RestCatalog(const std::string& base_url); - ~RestCatalog() = default; - - cpr::Response GetConfig(); - - cpr::Response ListNamespaces(); - - private: - std::string base_url_; -}; - -} // namespace iceberg::catalog::rest diff --git a/src/iceberg/catalog/rest/util.h b/src/iceberg/catalog/rest/util.h new file mode 100644 index 000000000..5c4abdbc6 --- /dev/null +++ b/src/iceberg/catalog/rest/util.h @@ -0,0 +1,83 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include +#include + +#include "iceberg/table_identifier.h" + +namespace iceberg::rest { + +/// \brief Trim a single trailing slash from a URI string_view. +/// \details If \p uri_sv ends with '/', remove that last character; otherwise the input +/// is returned unchanged. +/// \param uri_sv The URI string view to trim. +/// \return The (possibly) trimmed URI string view. +inline std::string_view TrimTrailingSlash(std::string_view uri_sv) { + if (uri_sv.ends_with('/')) { + uri_sv.remove_suffix(1); + } + return uri_sv; +} + +/// \brief Percent-encode a string as a URI component (RFC 3986). +/// \details Leaves unreserved characters [A–Z a–z 0–9 - _ . ~] as-is; all others are +/// percent-encoded +/// using uppercase hexadecimal (e.g., space -> "%20"). +/// \param value The string to encode. +/// \return The encoded string. +inline std::string EncodeUriComponent(std::string_view value) { + std::string escaped; + escaped.reserve(value.length()); + for (const unsigned char c : value) { + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + escaped += c; + } else { + std::format_to(std::back_inserter(escaped), "{:02X}", c); + } + } + return escaped; +} + +/// \brief Encode a Namespace into a URL-safe component. +/// \details Joins \p ns.levels with the ASCII Unit Separator (0x1F), then percent-encodes +/// the result +/// via EncodeUriComponent. Returns an empty string if there are no levels. +/// \param ns The namespace (sequence of path-like levels) to encode. +/// \return The percent-encoded namespace string suitable for URLs. +inline std::string EncodeNamespaceForUrl(const Namespace& ns) { + if (ns.levels.empty()) { + return ""; + } + + std::string joined_string; + joined_string.append(ns.levels.front()); + for (size_t i = 1; i < ns.levels.size(); ++i) { + joined_string.append("\x1F"); + joined_string.append(ns.levels[i]); + } + + return EncodeUriComponent(joined_string); +} + +} // namespace iceberg::rest diff --git a/src/iceberg/result.h b/src/iceberg/result.h index 99df37247..81a987c9e 100644 --- a/src/iceberg/result.h +++ b/src/iceberg/result.h @@ -33,22 +33,33 @@ enum class ErrorKind { kCommitFailed, kCommitStateUnknown, kDecompressError, + kIOError, kInvalid, // For general invalid errors kInvalidArgument, kInvalidArrowData, kInvalidExpression, - kInvalidSchema, kInvalidManifest, kInvalidManifestList, - kIOError, + kInvalidSchema, kJsonParseError, + kNamespaceNotEmpty, kNoSuchNamespace, + kNoSuchPlanId, + kNoSuchPlanTask, kNoSuchTable, + kNoSuchView, kNotAllowed, kNotFound, kNotImplemented, kNotSupported, kUnknownError, + + // Below: errors specific to Iceberg REST catalog + kAuthenticationTimeout, + kBadRequest, + kNotAuthorized, + kSlowDown, + kUnprocessableEntity, }; /// \brief Error with a kind and a message. @@ -91,14 +102,25 @@ DEFINE_ERROR_FUNCTION(InvalidManifest) DEFINE_ERROR_FUNCTION(InvalidManifestList) DEFINE_ERROR_FUNCTION(IOError) DEFINE_ERROR_FUNCTION(JsonParseError) +DEFINE_ERROR_FUNCTION(NamespaceNotEmpty) DEFINE_ERROR_FUNCTION(NoSuchNamespace) +DEFINE_ERROR_FUNCTION(NoSuchPlanId) +DEFINE_ERROR_FUNCTION(NoSuchPlanTask) DEFINE_ERROR_FUNCTION(NoSuchTable) +DEFINE_ERROR_FUNCTION(NoSuchView) DEFINE_ERROR_FUNCTION(NotAllowed) DEFINE_ERROR_FUNCTION(NotFound) DEFINE_ERROR_FUNCTION(NotImplemented) DEFINE_ERROR_FUNCTION(NotSupported) DEFINE_ERROR_FUNCTION(UnknownError) +// REST catalog specific errors +DEFINE_ERROR_FUNCTION(AuthenticationTimeout) +DEFINE_ERROR_FUNCTION(BadRequest) +DEFINE_ERROR_FUNCTION(NotAuthorized) +DEFINE_ERROR_FUNCTION(SlowDown) +DEFINE_ERROR_FUNCTION(UnprocessableEntity) + #undef DEFINE_ERROR_FUNCTION } // namespace iceberg diff --git a/src/iceberg/test/rest_catalog_test.cc b/src/iceberg/test/rest_catalog_test.cc index fda9ef6de..8b949028b 100644 --- a/src/iceberg/test/rest_catalog_test.cc +++ b/src/iceberg/test/rest_catalog_test.cc @@ -17,8 +17,6 @@ * under the License. */ -#include "iceberg/catalog/rest/rest_catalog.h" - #include #include @@ -28,84 +26,6 @@ #include #include -namespace iceberg::catalog::rest { - -class RestCatalogIntegrationTest : public ::testing::Test { - protected: - void SetUp() override { - server_ = std::make_unique(); - port_ = server_->bind_to_any_port("127.0.0.1"); - - server_thread_ = std::thread([this]() { server_->listen_after_bind(); }); - } - - void TearDown() override { - server_->stop(); - if (server_thread_.joinable()) { - server_thread_.join(); - } - } - - std::unique_ptr server_; - int port_ = -1; - std::thread server_thread_; -}; - -TEST_F(RestCatalogIntegrationTest, DISABLED_GetConfigSuccessfully) { - server_->Get("/v1/config", [](const httplib::Request&, httplib::Response& res) { - res.status = 200; - res.set_content(R"({"warehouse": "s3://test-bucket"})", "application/json"); - }); - - std::string base_uri = "http://127.0.0.1:" + std::to_string(port_); - RestCatalog catalog(base_uri); - cpr::Response response = catalog.GetConfig(); - - ASSERT_EQ(response.error.code, cpr::ErrorCode::OK); - ASSERT_EQ(response.status_code, 200); - - auto json_body = nlohmann::json::parse(response.text); - EXPECT_EQ(json_body["warehouse"], "s3://test-bucket"); -} - -TEST_F(RestCatalogIntegrationTest, DISABLED_ListNamespacesReturnsMultipleResults) { - server_->Get("/v1/namespaces", [](const httplib::Request&, httplib::Response& res) { - res.status = 200; - res.set_content(R"({ - "namespaces": [ - ["accounting", "db"], - ["production", "db"] - ] - })", - "application/json"); - }); - - std::string base_uri = "http://127.0.0.1:" + std::to_string(port_); - RestCatalog catalog(base_uri); - cpr::Response response = catalog.ListNamespaces(); - - ASSERT_EQ(response.error.code, cpr::ErrorCode::OK); - ASSERT_EQ(response.status_code, 200); - - auto json_body = nlohmann::json::parse(response.text); - ASSERT_TRUE(json_body.contains("namespaces")); - EXPECT_EQ(json_body["namespaces"].size(), 2); - EXPECT_THAT(json_body["namespaces"][0][0], "accounting"); -} - -TEST_F(RestCatalogIntegrationTest, DISABLED_HandlesServerError) { - server_->Get("/v1/config", [](const httplib::Request&, httplib::Response& res) { - res.status = 500; - res.set_content("Internal Server Error", "text/plain"); - }); - - std::string base_uri = "http://127.0.0.1:" + std::to_string(port_); - RestCatalog catalog(base_uri); - cpr::Response response = catalog.GetConfig(); - - ASSERT_EQ(response.error.code, cpr::ErrorCode::OK); - ASSERT_EQ(response.status_code, 500); - ASSERT_EQ(response.text, "Internal Server Error"); -} +#include "iceberg/catalog/rest/catalog.h" -} // namespace iceberg::catalog::rest +namespace iceberg::rest {} // namespace iceberg::rest