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 55f1c38b7..1ee03db85 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; @@ -79,6 +148,7 @@ Result CreateNamespaceRequestFromJson( ICEBERG_ASSIGN_OR_RAISE( request.properties, GetJsonValueOrDefault(json, kProperties)); + ICEBERG_RETURN_UNEXPECTED(Validator::Validate(request)); return request; } @@ -102,6 +172,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; } @@ -122,6 +193,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; } @@ -139,6 +211,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; } @@ -165,6 +238,7 @@ Result LoadTableResultFromJson(const nlohmann::json& json) { ICEBERG_ASSIGN_OR_RAISE( result.config, (GetJsonValueOrDefault>( json, kConfig))); + ICEBERG_RETURN_UNEXPECTED(Validator::Validate(result)); return result; } @@ -192,6 +266,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; } @@ -253,6 +328,7 @@ Result UpdateNamespacePropertiesResponseFromJ GetJsonValue>(json, kRemoved)); ICEBERG_ASSIGN_OR_RAISE( response.missing, GetJsonValueOrDefault>(json, kMissing)); + ICEBERG_RETURN_UNEXPECTED(Validator::Validate(response)); return response; } @@ -279,6 +355,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; } diff --git a/src/iceberg/catalog/rest/json_internal.h b/src/iceberg/catalog/rest/json_internal.h index 11b567a01..f5c3e8d44 100644 --- a/src/iceberg/catalog/rest/json_internal.h +++ b/src/iceberg/catalog/rest/json_internal.h @@ -24,8 +24,31 @@ #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 { +/// \brief Serializes a `CatalogConfig` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const CatalogConfig& config); + +/// \brief Deserializes a JSON object into a `CatalogConfig` object. +ICEBERG_REST_EXPORT Result CatalogConfigFromJson( + const nlohmann::json& json); + +/// \brief Serializes a `ErrorModel` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const ErrorModel& error); + +/// \brief Deserializes a JSON object into a `ErrorModel` object. +ICEBERG_REST_EXPORT Result ErrorModelFromJson(const nlohmann::json& json); + +/// \brief Serializes a `ErrorResponse` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const ErrorResponse& response); + +/// \brief Deserializes a JSON object into a `ErrorResponse` object. +ICEBERG_REST_EXPORT Result ErrorResponseFromJson( + const nlohmann::json& json); + /// \brief Serializes a `ListNamespacesResponse` object to JSON. ICEBERG_REST_EXPORT nlohmann::json ToJson(const ListNamespacesResponse& response); 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 c042f7f8d..048f8b3fd 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 @@ -91,6 +90,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; +} + struct CreateNamespaceRequestParam { std::string test_name; std::string expected_json_str; @@ -916,4 +929,301 @@ TEST(RegisterTableRequestTest, InvalidRequests) { EXPECT_EQ(result3.error().message, "Missing 'name' in {}"); } +struct CatalogConfigParam { + std::string test_name; + std::string expected_json_str; + std::unordered_map overrides; + std::unordered_map defaults; + std::vector endpoints; +}; + +class CatalogConfigTest : public ::testing::TestWithParam { + protected: + void TestRoundTrip() { + const auto& param = GetParam(); + + CatalogConfig original; + original.overrides = param.overrides; + original.defaults = param.defaults; + original.endpoints = param.endpoints; + + auto json = ToJson(original); + auto expected_json = nlohmann::json::parse(param.expected_json_str); + EXPECT_EQ(json, expected_json); + + auto result = CatalogConfigFromJson(expected_json); + ASSERT_TRUE(result.has_value()) << result.error().message; + auto& parsed = result.value(); + + EXPECT_EQ(parsed, original); + } +}; + +TEST_P(CatalogConfigTest, RoundTrip) { TestRoundTrip(); } + +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"}})", + .overrides = {{"clients", "5"}}, + .defaults = {{"warehouse", "s3://bucket/warehouse"}}, + .endpoints = {}}, + // Only defaults + CatalogConfigParam{ + .test_name = "OnlyDefaults", + .expected_json_str = + R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":{}})", + .overrides = {}, + .defaults = {{"warehouse", "s3://bucket/warehouse"}}, + .endpoints = {}}, + // Only overrides + CatalogConfigParam{ + .test_name = "OnlyOverrides", + .expected_json_str = R"({"defaults":{},"overrides":{"clients":"5"}})", + .overrides = {{"clients", "5"}}, + .defaults = {}, + .endpoints = {}}, + // Both empty + CatalogConfigParam{.test_name = "BothEmpty", + .expected_json_str = R"({"defaults":{},"overrides":{}})", + .overrides = {}, + .defaults = {}, + .endpoints = {}}, + // 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"]})", + .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"]})", + .overrides = {}, + .defaults = {}, + .endpoints = {"GET /v1/config"}}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +TEST(CatalogConfigTest, DeserializeWithoutDefaults) { + // Missing overrides field + std::string json_missing_overrides = + R"({"defaults":{"warehouse":"s3://bucket/warehouse"}})"; + auto result1 = CatalogConfigFromJson(nlohmann::json::parse(json_missing_overrides)); + ASSERT_TRUE(result1.has_value()); + std::unordered_map expected_defaults = { + {"warehouse", "s3://bucket/warehouse"}}; + EXPECT_EQ(result1.value().defaults, expected_defaults); + EXPECT_TRUE(result1.value().overrides.empty()); + + // Null overrides field + std::string json_null_overrides = + R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":null})"; + auto result2 = CatalogConfigFromJson(nlohmann::json::parse(json_null_overrides)); + ASSERT_TRUE(result2.has_value()); + EXPECT_TRUE(result2.value().overrides.empty()); + + // Missing defaults field + std::string json_missing_defaults = R"({"overrides":{"clients":"5"}})"; + auto result3 = CatalogConfigFromJson(nlohmann::json::parse(json_missing_defaults)); + ASSERT_TRUE(result3.has_value()); + std::unordered_map expected_overrides = {{"clients", "5"}}; + EXPECT_EQ(result3.value().overrides, expected_overrides); + EXPECT_TRUE(result3.value().defaults.empty()); + + // Null defaults field + std::string json_null_defaults = R"({"defaults":null,"overrides":{"clients":"5"}})"; + auto result4 = CatalogConfigFromJson(nlohmann::json::parse(json_null_defaults)); + ASSERT_TRUE(result4.has_value()); + EXPECT_TRUE(result4.value().defaults.empty()); + + // Empty JSON object + std::string json_empty = R"({})"; + auto result5 = CatalogConfigFromJson(nlohmann::json::parse(json_empty)); + ASSERT_TRUE(result5.has_value()); + EXPECT_TRUE(result5.value().defaults.empty()); + EXPECT_TRUE(result5.value().overrides.empty()); + + // Both fields null + std::string json_both_null = R"({"defaults":null,"overrides":null})"; + auto result6 = CatalogConfigFromJson(nlohmann::json::parse(json_both_null)); + ASSERT_TRUE(result6.has_value()); + EXPECT_TRUE(result6.value().defaults.empty()); + EXPECT_TRUE(result6.value().overrides.empty()); +} + +TEST(CatalogConfigTest, InvalidConfig) { + // Defaults has wrong type (array instead of object) + std::string json_wrong_defaults_type = + R"({"defaults":["warehouse","s3://bucket/warehouse"],"overrides":{"clients":"5"}})"; + auto result1 = CatalogConfigFromJson(nlohmann::json::parse(json_wrong_defaults_type)); + EXPECT_FALSE(result1.has_value()); + EXPECT_THAT(result1, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result1.error().message, + "Failed to parse 'defaults' from " + "{\"defaults\":[\"warehouse\",\"s3://bucket/" + "warehouse\"],\"overrides\":{\"clients\":\"5\"}}: " + "[json.exception.type_error.302] type must be object, but is array"); + + // Overrides has wrong type (string instead of object) + std::string json_wrong_overrides_type = + R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":"clients"})"; + auto result2 = CatalogConfigFromJson(nlohmann::json::parse(json_wrong_overrides_type)); + EXPECT_FALSE(result2.has_value()); + EXPECT_THAT(result2, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result2.error().message, + "Failed to parse 'overrides' from " + "{\"defaults\":{\"warehouse\":\"s3://bucket/" + "warehouse\"},\"overrides\":\"clients\"}: " + "[json.exception.type_error.302] type must be object, but is string"); +} + +struct ErrorResponseParam { + std::string test_name; + std::string expected_json_str; + std::string message; + std::string type; + uint16_t code; + std::vector stack; +}; + +class ErrorResponseTest : public ::testing::TestWithParam { + protected: + void TestRoundTrip() { + const auto& param = GetParam(); + + ErrorModel error_model; + error_model.message = param.message; + error_model.type = param.type; + error_model.code = param.code; + error_model.stack = param.stack; + + ErrorResponse original; + original.error = error_model; + + auto json = ToJson(original); + auto expected_json = nlohmann::json::parse(param.expected_json_str); + EXPECT_EQ(json, expected_json); + + auto result = ErrorResponseFromJson(expected_json); + ASSERT_TRUE(result.has_value()) << result.error().message; + auto& parsed = result.value(); + + EXPECT_EQ(parsed, original); + } +}; + +TEST_P(ErrorResponseTest, RoundTrip) { TestRoundTrip(); } + +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}})", + .message = "The given namespace does not exist", + .type = "NoSuchNamespaceException", + .code = 404, + .stack = {}}, + // 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"]}})", + .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"]}})", + .message = "Internal server error", + .type = "InternalServerError", + .code = 500, + .stack = {"line1", "line2", "line3"}}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +TEST(ErrorResponseTest, DeserializeWithExplicitNullStack) { + std::string json_null_stack = + R"({"error":{"message":"The given namespace does not exist","type":"NoSuchNamespaceException","code":404,"stack":null}})"; + auto result = ErrorResponseFromJson(nlohmann::json::parse(json_null_stack)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value().error.message, "The given namespace does not exist"); + EXPECT_EQ(result.value().error.type, "NoSuchNamespaceException"); + EXPECT_EQ(result.value().error.code, 404); + EXPECT_TRUE(result.value().error.stack.empty()); +} + +TEST(ErrorResponseTest, DeserializeWithMissingStack) { + std::string json_missing_stack = + R"({"error":{"message":"The given namespace does not exist","type":"NoSuchNamespaceException","code":404}})"; + auto result = ErrorResponseFromJson(nlohmann::json::parse(json_missing_stack)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value().error.message, "The given namespace does not exist"); + EXPECT_EQ(result.value().error.type, "NoSuchNamespaceException"); + EXPECT_EQ(result.value().error.code, 404); + EXPECT_TRUE(result.value().error.stack.empty()); +} + +TEST(ErrorResponseTest, InvalidErrorResponse) { + // Missing error field + std::string json_missing_error = R"({})"; + auto result1 = ErrorResponseFromJson(nlohmann::json::parse(json_missing_error)); + EXPECT_FALSE(result1.has_value()); + EXPECT_THAT(result1, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result1.error().message, "Missing 'error' in {}"); + + // Null error field + std::string json_null_error = R"({"error":null})"; + auto result2 = ErrorResponseFromJson(nlohmann::json::parse(json_null_error)); + EXPECT_FALSE(result2.has_value()); + EXPECT_THAT(result2, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result2.error().message, "Missing 'error' in {\"error\":null}"); + + // Missing required type field + std::string json_missing_type = + R"({"message":"The given namespace does not exist","code":404})"; + auto result3 = ErrorModelFromJson(nlohmann::json::parse(json_missing_type)); + EXPECT_FALSE(result3.has_value()); + EXPECT_THAT(result3, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result3.error().message, + "Missing 'type' in {\"code\":404,\"message\":\"The given namespace does not " + "exist\"}"); + + // Missing required code field + std::string json_missing_code = + R"({"message":"The given namespace does not exist","type":"NoSuchNamespaceException"})"; + auto result4 = ErrorModelFromJson(nlohmann::json::parse(json_missing_code)); + EXPECT_FALSE(result4.has_value()); + EXPECT_THAT(result4, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result4.error().message, + "Missing 'code' in {\"message\":\"The given namespace does not " + "exist\",\"type\":\"NoSuchNamespaceException\"}"); + + // Wrong type for message field + std::string json_wrong_message_type = + R"({"message":123,"type":"NoSuchNamespaceException","code":404})"; + auto result5 = ErrorModelFromJson(nlohmann::json::parse(json_wrong_message_type)); + EXPECT_FALSE(result5.has_value()); + EXPECT_THAT(result5, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result5.error().message, + "Failed to parse 'message' from " + "{\"code\":404,\"message\":123,\"type\":\"NoSuchNamespaceException\"}: " + "[json.exception.type_error.302] type must be string, but is number"); +} + } // namespace iceberg::rest