diff --git a/src/iceberg/catalog/rest/CMakeLists.txt b/src/iceberg/catalog/rest/CMakeLists.txt index 2f9c2f05e..38d897270 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) +set(ICEBERG_REST_SOURCES rest_catalog.cc json_internal.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 new file mode 100644 index 000000000..55f1c38b7 --- /dev/null +++ b/src/iceberg/catalog/rest/json_internal.cc @@ -0,0 +1,285 @@ +/* + * 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/json_internal.h" + +#include +#include +#include +#include + +#include + +#include "iceberg/catalog/rest/types.h" +#include "iceberg/json_internal.h" +#include "iceberg/table_identifier.h" +#include "iceberg/util/json_util_internal.h" +#include "iceberg/util/macros.h" + +namespace iceberg::rest { + +namespace { + +// REST API JSON field constants +constexpr std::string_view kNamespace = "namespace"; +constexpr std::string_view kNamespaces = "namespaces"; +constexpr std::string_view kProperties = "properties"; +constexpr std::string_view kRemovals = "removals"; +constexpr std::string_view kUpdates = "updates"; +constexpr std::string_view kUpdated = "updated"; +constexpr std::string_view kRemoved = "removed"; +constexpr std::string_view kMissing = "missing"; +constexpr std::string_view kNextPageToken = "next-page-token"; +constexpr std::string_view kName = "name"; +constexpr std::string_view kLocation = "location"; +constexpr std::string_view kSchema = "schema"; +constexpr std::string_view kPartitionSpec = "partition-spec"; +constexpr std::string_view kWriteOrder = "write-order"; +constexpr std::string_view kStageCreate = "stage-create"; +constexpr std::string_view kMetadataLocation = "metadata-location"; +constexpr std::string_view kOverwrite = "overwrite"; +constexpr std::string_view kSource = "source"; +constexpr std::string_view kDestination = "destination"; +constexpr std::string_view kMetadata = "metadata"; +constexpr std::string_view kConfig = "config"; +constexpr std::string_view kIdentifiers = "identifiers"; + +} // namespace + +nlohmann::json ToJson(const CreateNamespaceRequest& request) { + nlohmann::json json; + json[kNamespace] = request.namespace_.levels; + if (!request.properties.empty()) { + json[kProperties] = request.properties; + } + return json; +} + +Result CreateNamespaceRequestFromJson( + const nlohmann::json& json) { + CreateNamespaceRequest request; + ICEBERG_ASSIGN_OR_RAISE(request.namespace_.levels, + GetJsonValue>(json, kNamespace)); + ICEBERG_ASSIGN_OR_RAISE( + request.properties, + GetJsonValueOrDefault(json, kProperties)); + return request; +} + +nlohmann::json ToJson(const UpdateNamespacePropertiesRequest& request) { + // Initialize as an empty object so that when all optional fields are absent we return + // {} instead of null + nlohmann::json json = nlohmann::json::object(); + if (!request.removals.empty()) { + json[kRemovals] = request.removals; + } + if (!request.updates.empty()) { + json[kUpdates] = request.updates; + } + return json; +} + +Result UpdateNamespacePropertiesRequestFromJson( + const nlohmann::json& json) { + UpdateNamespacePropertiesRequest request; + ICEBERG_ASSIGN_OR_RAISE( + request.removals, GetJsonValueOrDefault>(json, kRemovals)); + ICEBERG_ASSIGN_OR_RAISE( + request.updates, GetJsonValueOrDefault(json, kUpdates)); + return request; +} + +nlohmann::json ToJson(const RegisterTableRequest& request) { + nlohmann::json json; + json[kName] = request.name; + json[kMetadataLocation] = request.metadata_location; + if (request.overwrite) { + json[kOverwrite] = request.overwrite; + } + return json; +} + +Result RegisterTableRequestFromJson(const nlohmann::json& json) { + RegisterTableRequest request; + ICEBERG_ASSIGN_OR_RAISE(request.name, GetJsonValue(json, kName)); + ICEBERG_ASSIGN_OR_RAISE(request.metadata_location, + GetJsonValue(json, kMetadataLocation)); + ICEBERG_ASSIGN_OR_RAISE(request.overwrite, + GetJsonValueOrDefault(json, kOverwrite, false)); + return request; +} + +nlohmann::json ToJson(const RenameTableRequest& request) { + nlohmann::json json; + json[kSource] = ToJson(request.source); + json[kDestination] = ToJson(request.destination); + return json; +} + +Result RenameTableRequestFromJson(const nlohmann::json& json) { + RenameTableRequest request; + ICEBERG_ASSIGN_OR_RAISE(auto source_json, GetJsonValue(json, kSource)); + ICEBERG_ASSIGN_OR_RAISE(request.source, TableIdentifierFromJson(source_json)); + ICEBERG_ASSIGN_OR_RAISE(auto dest_json, + GetJsonValue(json, kDestination)); + ICEBERG_ASSIGN_OR_RAISE(request.destination, TableIdentifierFromJson(dest_json)); + return request; +} + +// LoadTableResult (used by CreateTableResponse, LoadTableResponse) +nlohmann::json ToJson(const LoadTableResult& result) { + nlohmann::json json; + if (!result.metadata_location.empty()) { + json[kMetadataLocation] = result.metadata_location; + } + json[kMetadata] = ToJson(*result.metadata); + if (!result.config.empty()) { + json[kConfig] = result.config; + } + return json; +} + +Result LoadTableResultFromJson(const nlohmann::json& json) { + LoadTableResult result; + ICEBERG_ASSIGN_OR_RAISE(result.metadata_location, + GetJsonValueOrDefault(json, kMetadataLocation)); + ICEBERG_ASSIGN_OR_RAISE(auto metadata_json, + GetJsonValue(json, kMetadata)); + ICEBERG_ASSIGN_OR_RAISE(result.metadata, TableMetadataFromJson(metadata_json)); + ICEBERG_ASSIGN_OR_RAISE( + result.config, (GetJsonValueOrDefault>( + json, kConfig))); + return result; +} + +nlohmann::json ToJson(const ListNamespacesResponse& response) { + nlohmann::json json; + if (!response.next_page_token.empty()) { + json[kNextPageToken] = response.next_page_token; + } + nlohmann::json namespaces = nlohmann::json::array(); + for (const auto& ns : response.namespaces) { + namespaces.push_back(ToJson(ns)); + } + json[kNamespaces] = std::move(namespaces); + return json; +} + +Result ListNamespacesResponseFromJson( + const nlohmann::json& json) { + ListNamespacesResponse response; + ICEBERG_ASSIGN_OR_RAISE(response.next_page_token, + GetJsonValueOrDefault(json, kNextPageToken)); + ICEBERG_ASSIGN_OR_RAISE(auto namespaces_json, + GetJsonValue(json, kNamespaces)); + for (const auto& ns_json : namespaces_json) { + ICEBERG_ASSIGN_OR_RAISE(auto ns, NamespaceFromJson(ns_json)); + response.namespaces.push_back(std::move(ns)); + } + return response; +} + +nlohmann::json ToJson(const CreateNamespaceResponse& response) { + nlohmann::json json; + json[kNamespace] = response.namespace_.levels; + if (!response.properties.empty()) { + json[kProperties] = response.properties; + } + return json; +} + +Result CreateNamespaceResponseFromJson( + const nlohmann::json& json) { + CreateNamespaceResponse response; + ICEBERG_ASSIGN_OR_RAISE(response.namespace_.levels, + GetJsonValue>(json, kNamespace)); + ICEBERG_ASSIGN_OR_RAISE( + response.properties, + GetJsonValueOrDefault(json, kProperties)); + return response; +} + +nlohmann::json ToJson(const GetNamespaceResponse& response) { + nlohmann::json json; + json[kNamespace] = response.namespace_.levels; + if (!response.properties.empty()) { + json[kProperties] = response.properties; + } + return json; +} + +Result GetNamespaceResponseFromJson(const nlohmann::json& json) { + GetNamespaceResponse response; + ICEBERG_ASSIGN_OR_RAISE(response.namespace_.levels, + GetJsonValue>(json, kNamespace)); + ICEBERG_ASSIGN_OR_RAISE( + response.properties, + GetJsonValueOrDefault(json, kProperties)); + return response; +} + +nlohmann::json ToJson(const UpdateNamespacePropertiesResponse& response) { + nlohmann::json json; + json[kUpdated] = response.updated; + json[kRemoved] = response.removed; + if (!response.missing.empty()) { + json[kMissing] = response.missing; + } + return json; +} + +Result UpdateNamespacePropertiesResponseFromJson( + const nlohmann::json& json) { + UpdateNamespacePropertiesResponse response; + ICEBERG_ASSIGN_OR_RAISE(response.updated, + GetJsonValue>(json, kUpdated)); + ICEBERG_ASSIGN_OR_RAISE(response.removed, + GetJsonValue>(json, kRemoved)); + ICEBERG_ASSIGN_OR_RAISE( + response.missing, GetJsonValueOrDefault>(json, kMissing)); + return response; +} + +nlohmann::json ToJson(const ListTablesResponse& response) { + nlohmann::json json; + if (!response.next_page_token.empty()) { + json[kNextPageToken] = response.next_page_token; + } + nlohmann::json identifiers_json = nlohmann::json::array(); + for (const auto& identifier : response.identifiers) { + identifiers_json.push_back(ToJson(identifier)); + } + json[kIdentifiers] = identifiers_json; + return json; +} + +Result ListTablesResponseFromJson(const nlohmann::json& json) { + ListTablesResponse response; + ICEBERG_ASSIGN_OR_RAISE(response.next_page_token, + GetJsonValueOrDefault(json, kNextPageToken)); + ICEBERG_ASSIGN_OR_RAISE(auto identifiers_json, + GetJsonValue(json, kIdentifiers)); + for (const auto& id_json : identifiers_json) { + ICEBERG_ASSIGN_OR_RAISE(auto identifier, TableIdentifierFromJson(id_json)); + response.identifiers.push_back(std::move(identifier)); + } + return response; +} + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/json_internal.h b/src/iceberg/catalog/rest/json_internal.h new file mode 100644 index 000000000..11b567a01 --- /dev/null +++ b/src/iceberg/catalog/rest/json_internal.h @@ -0,0 +1,101 @@ +/* + * 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 "iceberg/catalog/rest/types.h" +#include "iceberg/result.h" + +namespace iceberg::rest { + +/// \brief Serializes a `ListNamespacesResponse` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const ListNamespacesResponse& response); + +/// \brief Deserializes a JSON object into a `ListNamespacesResponse` object. +ICEBERG_REST_EXPORT Result ListNamespacesResponseFromJson( + const nlohmann::json& json); + +/// \brief Serializes a `CreateNamespaceRequest` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const CreateNamespaceRequest& request); + +/// \brief Deserializes a JSON object into a `CreateNamespaceRequest` object. +ICEBERG_REST_EXPORT Result CreateNamespaceRequestFromJson( + const nlohmann::json& json); + +/// \brief Serializes a `CreateNamespaceResponse` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const CreateNamespaceResponse& response); + +/// \brief Deserializes a JSON object into a `CreateNamespaceResponse` object. +ICEBERG_REST_EXPORT Result CreateNamespaceResponseFromJson( + const nlohmann::json& json); + +/// \brief Serializes a `GetNamespaceResponse` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const GetNamespaceResponse& response); + +/// \brief Deserializes a JSON object into a `GetNamespaceResponse` object. +ICEBERG_REST_EXPORT Result GetNamespaceResponseFromJson( + const nlohmann::json& json); + +/// \brief Serializes an `UpdateNamespacePropertiesRequest` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson( + const UpdateNamespacePropertiesRequest& request); + +/// \brief Deserializes a JSON object into an `UpdateNamespacePropertiesRequest` object. +ICEBERG_REST_EXPORT Result +UpdateNamespacePropertiesRequestFromJson(const nlohmann::json& json); + +/// \brief Serializes an `UpdateNamespacePropertiesResponse` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson( + const UpdateNamespacePropertiesResponse& response); + +/// \brief Deserializes a JSON object into an `UpdateNamespacePropertiesResponse` object. +ICEBERG_REST_EXPORT Result +UpdateNamespacePropertiesResponseFromJson(const nlohmann::json& json); + +/// \brief Serializes a `ListTablesResponse` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const ListTablesResponse& response); + +/// \brief Deserializes a JSON object into a `ListTablesResponse` object. +ICEBERG_REST_EXPORT Result ListTablesResponseFromJson( + const nlohmann::json& json); + +/// \brief Serializes a `LoadTableResult` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const LoadTableResult& result); + +/// \brief Deserializes a JSON object into a `LoadTableResult` object. +ICEBERG_REST_EXPORT Result LoadTableResultFromJson( + const nlohmann::json& json); + +/// \brief Serializes a `RegisterTableRequest` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const RegisterTableRequest& request); + +/// \brief Deserializes a JSON object into a `RegisterTableRequest` object. +ICEBERG_REST_EXPORT Result RegisterTableRequestFromJson( + const nlohmann::json& json); + +/// \brief Serializes a `RenameTableRequest` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const RenameTableRequest& request); + +/// \brief Deserializes a JSON object into a `RenameTableRequest` object. +ICEBERG_REST_EXPORT Result RenameTableRequestFromJson( + const nlohmann::json& json); + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/meson.build b/src/iceberg/catalog/rest/meson.build index 9d8a7d384..5f1f635ab 100644 --- a/src/iceberg/catalog/rest/meson.build +++ b/src/iceberg/catalog/rest/meson.build @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -iceberg_rest_sources = files('rest_catalog.cc') +iceberg_rest_sources = files('json_internal.cc', 'rest_catalog.cc') # cpr does not export symbols, so on Windows it must # be used as a static lib cpr_needs_static = ( diff --git a/src/iceberg/catalog/rest/types.h b/src/iceberg/catalog/rest/types.h index 4c50ab268..11411cdb7 100644 --- a/src/iceberg/catalog/rest/types.h +++ b/src/iceberg/catalog/rest/types.h @@ -46,17 +46,6 @@ struct ICEBERG_REST_EXPORT UpdateNamespacePropertiesRequest { std::unordered_map updates; }; -/// \brief Request to create a table. -struct ICEBERG_REST_EXPORT CreateTableRequest { - std::string name; // required - std::string location; - std::shared_ptr schema; // required - std::shared_ptr partition_spec; - std::shared_ptr write_order; - std::optional stage_create; - std::unordered_map properties; -}; - /// \brief Request to register a table. struct ICEBERG_REST_EXPORT RegisterTableRequest { std::string name; // required @@ -75,8 +64,8 @@ using PageToken = std::string; /// \brief Result body for table create/load/register APIs. struct ICEBERG_REST_EXPORT LoadTableResult { - std::optional metadata_location; - std::shared_ptr metadata; // required // required + std::string metadata_location; + std::shared_ptr metadata; // required std::unordered_map config; // TODO(Li Feiyang): Add std::shared_ptr storage_credential; }; diff --git a/src/iceberg/json_internal.cc b/src/iceberg/json_internal.cc index 0ad546197..1bad8cc4b 100644 --- a/src/iceberg/json_internal.cc +++ b/src/iceberg/json_internal.cc @@ -37,6 +37,7 @@ #include "iceberg/snapshot.h" #include "iceberg/sort_order.h" #include "iceberg/statistics_file.h" +#include "iceberg/table_identifier.h" #include "iceberg/table_metadata.h" #include "iceberg/transform.h" #include "iceberg/type.h" @@ -73,6 +74,7 @@ constexpr std::string_view kKey = "key"; constexpr std::string_view kValue = "value"; constexpr std::string_view kDoc = "doc"; constexpr std::string_view kName = "name"; +constexpr std::string_view kNamespace = "namespace"; constexpr std::string_view kNames = "names"; constexpr std::string_view kId = "id"; constexpr std::string_view kInitialDefault = "initial-default"; @@ -1147,4 +1149,32 @@ Result> NameMappingFromJson(const nlohmann::json& j return NameMapping::Make(std::move(mapped_fields)); } +nlohmann::json ToJson(const TableIdentifier& identifier) { + nlohmann::json json; + json[kNamespace] = identifier.ns.levels; + json[kName] = identifier.name; + return json; +} + +Result TableIdentifierFromJson(const nlohmann::json& json) { + TableIdentifier identifier; + ICEBERG_ASSIGN_OR_RAISE( + identifier.ns.levels, + GetJsonValueOrDefault>(json, kNamespace)); + ICEBERG_ASSIGN_OR_RAISE(identifier.name, GetJsonValue(json, kName)); + + return identifier; +} + +nlohmann::json ToJson(const Namespace& ns) { return ns.levels; } + +Result NamespaceFromJson(const nlohmann::json& json) { + if (!json.is_array()) [[unlikely]] { + return JsonParseError("Cannot parse namespace from non-array:{}", SafeDumpJson(json)); + } + Namespace ns; + ICEBERG_ASSIGN_OR_RAISE(ns.levels, GetTypedJsonValue>(json)); + return ns; +} + } // namespace iceberg diff --git a/src/iceberg/json_internal.h b/src/iceberg/json_internal.h index d5eb5bcd4..894bc6eb3 100644 --- a/src/iceberg/json_internal.h +++ b/src/iceberg/json_internal.h @@ -327,4 +327,30 @@ ICEBERG_EXPORT nlohmann::json ToJson(const NameMapping& name_mapping); ICEBERG_EXPORT Result> NameMappingFromJson( const nlohmann::json& json); +/// \brief Serializes a `TableIdentifier` object to JSON. +/// +/// \param[in] identifier The `TableIdentifier` object to be serialized. +/// \return A JSON object representing the `TableIdentifier` in the form of key-value +/// pairs. +ICEBERG_EXPORT nlohmann::json ToJson(const TableIdentifier& identifier); + +/// \brief Deserializes a JSON object into a `TableIdentifier` object. +/// +/// \param[in] json The JSON object representing a `TableIdentifier`. +/// \return A `TableIdentifier` object or an error if the conversion fails. +ICEBERG_EXPORT Result TableIdentifierFromJson( + const nlohmann::json& json); + +/// \brief Serializes a `Namespace` object to JSON. +/// +/// \param[in] ns The `Namespace` object to be serialized. +/// \return A JSON array representing the namespace levels. +ICEBERG_EXPORT nlohmann::json ToJson(const Namespace& ns); + +/// \brief Deserializes a JSON array into a `Namespace` object. +/// +/// \param[in] json The JSON array representing a `Namespace`. +/// \return A `Namespace` object or an error if the conversion fails. +ICEBERG_EXPORT Result NamespaceFromJson(const nlohmann::json& json); + } // namespace iceberg diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt index 68af62bf1..ec07a4347 100644 --- a/src/iceberg/test/CMakeLists.txt +++ b/src/iceberg/test/CMakeLists.txt @@ -150,7 +150,8 @@ if(ICEBERG_BUILD_BUNDLE) endif() if(ICEBERG_BUILD_REST) - add_iceberg_test(rest_catalog_test SOURCES rest_catalog_test.cc) + add_iceberg_test(rest_catalog_test SOURCES rest_catalog_test.cc + rest_json_internal_test.cc) target_link_libraries(rest_catalog_test PRIVATE iceberg_rest_static) target_include_directories(rest_catalog_test PRIVATE ${cpp-httplib_SOURCE_DIR}) endif() diff --git a/src/iceberg/test/meson.build b/src/iceberg/test/meson.build index 88b16325c..5f5db0900 100644 --- a/src/iceberg/test/meson.build +++ b/src/iceberg/test/meson.build @@ -86,7 +86,10 @@ if get_option('rest').enabled() cpp_httplib_dep = dependency('cpp-httplib') iceberg_tests += { 'rest_catalog_test': { - 'sources': files('rest_catalog_test.cc'), + 'sources': files( + 'rest_catalog_test.cc', + 'rest_json_internal_test.cc', + ), 'dependencies': [iceberg_rest_dep, cpp_httplib_dep], }, } diff --git a/src/iceberg/test/rest_json_internal_test.cc b/src/iceberg/test/rest_json_internal_test.cc new file mode 100644 index 000000000..c042f7f8d --- /dev/null +++ b/src/iceberg/test/rest_json_internal_test.cc @@ -0,0 +1,919 @@ +/* + * 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 +#include +#include + +#include +#include +#include + +#include "iceberg/catalog/rest/json_internal.h" +#include "iceberg/catalog/rest/types.h" +#include "iceberg/result.h" +#include "iceberg/table_identifier.h" +#include "iceberg/test/matchers.h" + +namespace iceberg::rest { + +bool operator==(const CreateNamespaceRequest& lhs, const CreateNamespaceRequest& rhs) { + return lhs.namespace_.levels == rhs.namespace_.levels && + lhs.properties == rhs.properties; +} + +bool operator==(const UpdateNamespacePropertiesRequest& lhs, + const UpdateNamespacePropertiesRequest& rhs) { + return lhs.removals == rhs.removals && lhs.updates == rhs.updates; +} + +bool operator==(const RegisterTableRequest& lhs, const RegisterTableRequest& rhs) { + return lhs.name == rhs.name && lhs.metadata_location == rhs.metadata_location && + lhs.overwrite == rhs.overwrite; +} + +bool operator==(const CreateNamespaceResponse& lhs, const CreateNamespaceResponse& rhs) { + return lhs.namespace_.levels == rhs.namespace_.levels && + lhs.properties == rhs.properties; +} + +bool operator==(const GetNamespaceResponse& lhs, const GetNamespaceResponse& rhs) { + return lhs.namespace_.levels == rhs.namespace_.levels && + lhs.properties == rhs.properties; +} + +bool operator==(const ListNamespacesResponse& lhs, const ListNamespacesResponse& rhs) { + if (lhs.namespaces.size() != rhs.namespaces.size()) return false; + for (size_t i = 0; i < lhs.namespaces.size(); ++i) { + if (lhs.namespaces[i].levels != rhs.namespaces[i].levels) return false; + } + return lhs.next_page_token == rhs.next_page_token; +} + +bool operator==(const UpdateNamespacePropertiesResponse& lhs, + const UpdateNamespacePropertiesResponse& rhs) { + return lhs.updated == rhs.updated && lhs.removed == rhs.removed && + lhs.missing == rhs.missing; +} + +bool operator==(const ListTablesResponse& lhs, const ListTablesResponse& rhs) { + if (lhs.identifiers.size() != rhs.identifiers.size()) return false; + for (size_t i = 0; i < lhs.identifiers.size(); ++i) { + if (lhs.identifiers[i].ns.levels != rhs.identifiers[i].ns.levels || + lhs.identifiers[i].name != rhs.identifiers[i].name) { + return false; + } + } + return lhs.next_page_token == rhs.next_page_token; +} + +bool operator==(const RenameTableRequest& lhs, const RenameTableRequest& rhs) { + return lhs.source.ns.levels == rhs.source.ns.levels && + lhs.source.name == rhs.source.name && + lhs.destination.ns.levels == rhs.destination.ns.levels && + lhs.destination.name == rhs.destination.name; +} + +struct CreateNamespaceRequestParam { + std::string test_name; + std::string expected_json_str; + Namespace namespace_; + std::unordered_map properties; +}; + +class CreateNamespaceRequestTest + : public ::testing::TestWithParam { + protected: + void TestRoundTrip() { + const auto& param = GetParam(); + + // Build original object + CreateNamespaceRequest original; + original.namespace_ = param.namespace_; + original.properties = param.properties; + + // ToJson and verify JSON string + auto json = ToJson(original); + auto expected_json = nlohmann::json::parse(param.expected_json_str); + EXPECT_EQ(json, expected_json) << "ToJson mismatch"; + + // FromJson and verify object equality + auto result = CreateNamespaceRequestFromJson(expected_json); + ASSERT_TRUE(result.has_value()) << result.error().message; + auto& parsed = result.value(); + + EXPECT_EQ(parsed, original); + } +}; + +TEST_P(CreateNamespaceRequestTest, RoundTrip) { TestRoundTrip(); } + +INSTANTIATE_TEST_SUITE_P( + CreateNamespaceRequestCases, CreateNamespaceRequestTest, + ::testing::Values( + // Full request with properties + CreateNamespaceRequestParam{ + .test_name = "FullRequest", + .expected_json_str = + R"({"namespace":["accounting","tax"],"properties":{"owner":"Hank"}})", + .namespace_ = Namespace{{"accounting", "tax"}}, + .properties = {{"owner", "Hank"}}, + }, + // Request with empty properties (omit properties field when empty) + CreateNamespaceRequestParam{ + .test_name = "EmptyProperties", + .expected_json_str = R"({"namespace":["accounting","tax"]})", + .namespace_ = Namespace{{"accounting", "tax"}}, + .properties = {}, + }, + // Request with empty namespace + CreateNamespaceRequestParam{ + .test_name = "EmptyNamespace", + .expected_json_str = R"({"namespace":[]})", + .namespace_ = Namespace{}, + .properties = {}, + }), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +TEST(CreateNamespaceRequestTest, DeserializeWithoutDefaults) { + // Properties is null + std::string json_null_props = R"({"namespace":["accounting","tax"],"properties":null})"; + auto result1 = CreateNamespaceRequestFromJson(nlohmann::json::parse(json_null_props)); + ASSERT_TRUE(result1.has_value()); + EXPECT_EQ(result1.value().namespace_.levels, + std::vector({"accounting", "tax"})); + EXPECT_TRUE(result1.value().properties.empty()); + + // Properties is missing + std::string json_missing_props = R"({"namespace":["accounting","tax"]})"; + auto result2 = + CreateNamespaceRequestFromJson(nlohmann::json::parse(json_missing_props)); + ASSERT_TRUE(result2.has_value()); + EXPECT_EQ(result2.value().namespace_.levels, + std::vector({"accounting", "tax"})); + EXPECT_TRUE(result2.value().properties.empty()); +} + +TEST(CreateNamespaceRequestTest, InvalidRequests) { + // Incorrect type for namespace + std::string json_wrong_ns_type = + R"({"namespace":"accounting%1Ftax","properties":null})"; + auto result1 = + CreateNamespaceRequestFromJson(nlohmann::json::parse(json_wrong_ns_type)); + EXPECT_FALSE(result1.has_value()); + EXPECT_THAT(result1, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result1.error().message, + "Failed to parse 'namespace' from " + "{\"namespace\":\"accounting%1Ftax\",\"properties\":null}: " + "[json.exception.type_error.302] type must be array, but is string"); + + // Incorrect type for properties + std::string json_wrong_props_type = + R"({"namespace":["accounting","tax"],"properties":[]})"; + auto result2 = + CreateNamespaceRequestFromJson(nlohmann::json::parse(json_wrong_props_type)); + EXPECT_FALSE(result2.has_value()); + EXPECT_THAT(result2, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result2.error().message, + "Failed to parse 'properties' from " + "{\"namespace\":[\"accounting\",\"tax\"],\"properties\":[]}: " + "[json.exception.type_error.302] type must be object, but is array"); + + // Misspelled keys + std::string json_misspelled = + R"({"namepsace":["accounting","tax"],"propertiezzzz":{"owner":"Hank"}})"; + auto result3 = CreateNamespaceRequestFromJson(nlohmann::json::parse(json_misspelled)); + EXPECT_FALSE(result3.has_value()); + EXPECT_THAT(result3, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ( + result3.error().message, + "Missing 'namespace' in " + "{\"namepsace\":[\"accounting\",\"tax\"],\"propertiezzzz\":{\"owner\":\"Hank\"}}"); + + // Empty JSON + std::string json_empty = R"({})"; + auto result4 = CreateNamespaceRequestFromJson(nlohmann::json::parse(json_empty)); + EXPECT_FALSE(result4.has_value()); + EXPECT_THAT(result4, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result4.error().message, "Missing 'namespace' in {}"); +} + +struct CreateNamespaceResponseParam { + std::string test_name; + std::string expected_json_str; + Namespace namespace_; + std::unordered_map properties; +}; + +class CreateNamespaceResponseTest + : public ::testing::TestWithParam { + protected: + void TestRoundTrip() { + const auto& param = GetParam(); + + CreateNamespaceResponse original; + original.namespace_ = param.namespace_; + original.properties = param.properties; + + auto json = ToJson(original); + auto expected_json = nlohmann::json::parse(param.expected_json_str); + EXPECT_EQ(json, expected_json); + + auto result = CreateNamespaceResponseFromJson(expected_json); + ASSERT_TRUE(result.has_value()) << result.error().message; + auto& parsed = result.value(); + + EXPECT_EQ(parsed, original); + } +}; + +TEST_P(CreateNamespaceResponseTest, RoundTrip) { TestRoundTrip(); } + +INSTANTIATE_TEST_SUITE_P( + CreateNamespaceResponseCases, CreateNamespaceResponseTest, + ::testing::Values( + CreateNamespaceResponseParam{ + .test_name = "FullResponse", + .expected_json_str = + R"({"namespace":["accounting","tax"],"properties":{"owner":"Hank"}})", + .namespace_ = Namespace{{"accounting", "tax"}}, + .properties = {{"owner", "Hank"}}, + }, + CreateNamespaceResponseParam{ + .test_name = "EmptyProperties", + .expected_json_str = R"({"namespace":["accounting","tax"]})", + .namespace_ = Namespace{{"accounting", "tax"}}, + .properties = {}, + }, + CreateNamespaceResponseParam{.test_name = "EmptyNamespace", + .expected_json_str = R"({"namespace":[]})", + .namespace_ = Namespace{}, + .properties = {}}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +TEST(CreateNamespaceResponseTest, DeserializeWithoutDefaults) { + std::string json_missing_props = R"({"namespace":["accounting","tax"]})"; + auto result1 = + CreateNamespaceResponseFromJson(nlohmann::json::parse(json_missing_props)); + ASSERT_TRUE(result1.has_value()); + EXPECT_TRUE(result1.value().properties.empty()); + + std::string json_null_props = R"({"namespace":["accounting","tax"],"properties":null})"; + auto result2 = CreateNamespaceResponseFromJson(nlohmann::json::parse(json_null_props)); + ASSERT_TRUE(result2.has_value()); + EXPECT_TRUE(result2.value().properties.empty()); +} + +TEST(CreateNamespaceResponseTest, InvalidResponses) { + std::string json_wrong_ns_type = + R"({"namespace":"accounting%1Ftax","properties":null})"; + auto result1 = + CreateNamespaceResponseFromJson(nlohmann::json::parse(json_wrong_ns_type)); + EXPECT_FALSE(result1.has_value()); + EXPECT_THAT(result1, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result1.error().message, + "Failed to parse 'namespace' from " + "{\"namespace\":\"accounting%1Ftax\",\"properties\":null}: " + "[json.exception.type_error.302] type must be array, but is string"); + + std::string json_wrong_props_type = + R"({"namespace":["accounting","tax"],"properties":[]})"; + auto result2 = + CreateNamespaceResponseFromJson(nlohmann::json::parse(json_wrong_props_type)); + EXPECT_FALSE(result2.has_value()); + EXPECT_THAT(result2, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result2.error().message, + "Failed to parse 'properties' from " + "{\"namespace\":[\"accounting\",\"tax\"],\"properties\":[]}: " + "[json.exception.type_error.302] type must be object, but is array"); + + std::string json_empty = R"({})"; + auto result3 = CreateNamespaceResponseFromJson(nlohmann::json::parse(json_empty)); + EXPECT_FALSE(result3.has_value()); + EXPECT_THAT(result3, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result3.error().message, "Missing 'namespace' in {}"); +} + +struct GetNamespaceResponseParam { + std::string test_name; + std::string expected_json_str; + Namespace namespace_; + std::unordered_map properties; +}; + +class GetNamespaceResponseTest + : public ::testing::TestWithParam { + protected: + void TestRoundTrip() { + const auto& param = GetParam(); + + GetNamespaceResponse original; + original.namespace_ = param.namespace_; + original.properties = param.properties; + + auto json = ToJson(original); + auto expected_json = nlohmann::json::parse(param.expected_json_str); + EXPECT_EQ(json, expected_json); + + auto result = GetNamespaceResponseFromJson(expected_json); + ASSERT_TRUE(result.has_value()) << result.error().message; + auto& parsed = result.value(); + + EXPECT_EQ(parsed, original); + } +}; + +TEST_P(GetNamespaceResponseTest, RoundTrip) { TestRoundTrip(); } + +INSTANTIATE_TEST_SUITE_P( + GetNamespaceResponseCases, GetNamespaceResponseTest, + ::testing::Values( + GetNamespaceResponseParam{ + .test_name = "FullResponse", + .expected_json_str = + R"({"namespace":["accounting","tax"],"properties":{"owner":"Hank"}})", + .namespace_ = Namespace{{"accounting", "tax"}}, + .properties = {{"owner", "Hank"}}}, + GetNamespaceResponseParam{ + .test_name = "EmptyProperties", + .expected_json_str = R"({"namespace":["accounting","tax"]})", + .namespace_ = Namespace{{"accounting", "tax"}}, + .properties = {}}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +TEST(GetNamespaceResponseTest, DeserializeWithoutDefaults) { + std::string json_null_props = R"({"namespace":["accounting","tax"],"properties":null})"; + auto result = GetNamespaceResponseFromJson(nlohmann::json::parse(json_null_props)); + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result.value().properties.empty()); +} + +TEST(GetNamespaceResponseTest, InvalidResponses) { + std::string json_wrong_ns_type = + R"({"namespace":"accounting%1Ftax","properties":null})"; + auto result1 = GetNamespaceResponseFromJson(nlohmann::json::parse(json_wrong_ns_type)); + EXPECT_FALSE(result1.has_value()); + EXPECT_THAT(result1, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result1.error().message, + "Failed to parse 'namespace' from " + "{\"namespace\":\"accounting%1Ftax\",\"properties\":null}: " + "[json.exception.type_error.302] type must be array, but is string"); + + std::string json_wrong_props_type = + R"({"namespace":["accounting","tax"],"properties":[]})"; + auto result2 = + GetNamespaceResponseFromJson(nlohmann::json::parse(json_wrong_props_type)); + EXPECT_FALSE(result2.has_value()); + EXPECT_THAT(result2, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result2.error().message, + "Failed to parse 'properties' from " + "{\"namespace\":[\"accounting\",\"tax\"],\"properties\":[]}: " + "[json.exception.type_error.302] type must be object, but is array"); + + std::string json_empty = R"({})"; + auto result3 = GetNamespaceResponseFromJson(nlohmann::json::parse(json_empty)); + EXPECT_FALSE(result3.has_value()); + EXPECT_THAT(result3, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result3.error().message, "Missing 'namespace' in {}"); +} + +struct ListNamespacesResponseParam { + std::string test_name; + std::string expected_json_str; + std::vector namespaces; + std::string next_page_token; +}; + +class ListNamespacesResponseTest + : public ::testing::TestWithParam { + protected: + void TestRoundTrip() { + const auto& param = GetParam(); + + ListNamespacesResponse original; + original.namespaces = param.namespaces; + original.next_page_token = param.next_page_token; + + auto json = ToJson(original); + auto expected_json = nlohmann::json::parse(param.expected_json_str); + EXPECT_EQ(json, expected_json); + + auto result = ListNamespacesResponseFromJson(expected_json); + ASSERT_TRUE(result.has_value()) << result.error().message; + auto& parsed = result.value(); + + EXPECT_EQ(parsed, original); + } +}; + +TEST_P(ListNamespacesResponseTest, RoundTrip) { TestRoundTrip(); } + +INSTANTIATE_TEST_SUITE_P( + ListNamespacesResponseCases, ListNamespacesResponseTest, + ::testing::Values( + ListNamespacesResponseParam{ + .test_name = "FullResponse", + .expected_json_str = R"({"namespaces":[["accounting"],["tax"]]})", + .namespaces = {Namespace{{"accounting"}}, Namespace{{"tax"}}}, + .next_page_token = ""}, + ListNamespacesResponseParam{.test_name = "EmptyNamespaces", + .expected_json_str = R"({"namespaces":[]})", + .namespaces = {}, + .next_page_token = ""}, + ListNamespacesResponseParam{ + .test_name = "WithPageToken", + .expected_json_str = + R"({"namespaces":[["accounting"],["tax"]],"next-page-token":"token"})", + .namespaces = {Namespace{{"accounting"}}, Namespace{{"tax"}}}, + .next_page_token = "token"}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +TEST(ListNamespacesResponseTest, InvalidResponses) { + std::string json_wrong_type = R"({"namespaces":"accounting"})"; + auto result1 = ListNamespacesResponseFromJson(nlohmann::json::parse(json_wrong_type)); + EXPECT_FALSE(result1.has_value()); + EXPECT_THAT(result1, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result1.error().message, + "Cannot parse namespace from non-array:\"accounting\""); + + std::string json_empty = R"({})"; + auto result2 = ListNamespacesResponseFromJson(nlohmann::json::parse(json_empty)); + EXPECT_FALSE(result2.has_value()); + EXPECT_THAT(result2, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result2.error().message, "Missing 'namespaces' in {}"); +} + +struct UpdateNamespacePropertiesRequestParam { + std::string test_name; + std::string expected_json_str; + std::vector removals; + std::unordered_map updates; +}; + +class UpdateNamespacePropertiesRequestTest + : public ::testing::TestWithParam { + protected: + void TestRoundTrip() { + const auto& param = GetParam(); + + UpdateNamespacePropertiesRequest original; + original.removals = param.removals; + original.updates = param.updates; + + auto json = ToJson(original); + auto expected_json = nlohmann::json::parse(param.expected_json_str); + EXPECT_EQ(json, expected_json); + + auto result = UpdateNamespacePropertiesRequestFromJson(expected_json); + ASSERT_TRUE(result.has_value()) << result.error().message; + auto& parsed = result.value(); + + EXPECT_EQ(parsed, original); + } +}; + +TEST_P(UpdateNamespacePropertiesRequestTest, RoundTrip) { TestRoundTrip(); } + +INSTANTIATE_TEST_SUITE_P( + UpdateNamespacePropertiesRequestCases, UpdateNamespacePropertiesRequestTest, + ::testing::Values( + UpdateNamespacePropertiesRequestParam{ + .test_name = "FullRequest", + .expected_json_str = + R"({"removals":["foo","bar"],"updates":{"owner":"Hank"}})", + .removals = {"foo", "bar"}, + .updates = {{"owner", "Hank"}}}, + UpdateNamespacePropertiesRequestParam{ + .test_name = "OnlyUpdates", + .expected_json_str = R"({"updates":{"owner":"Hank"}})", + .removals = {}, + .updates = {{"owner", "Hank"}}}, + UpdateNamespacePropertiesRequestParam{ + .test_name = "OnlyRemovals", + .expected_json_str = R"({"removals":["foo","bar"]})", + .removals = {"foo", "bar"}, + .updates = {}}, + UpdateNamespacePropertiesRequestParam{.test_name = "AllEmpty", + .expected_json_str = R"({})", + .removals = {}, + .updates = {}}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +TEST(UpdateNamespacePropertiesRequestTest, DeserializeWithoutDefaults) { + // Removals is null + std::string json1 = R"({"removals":null,"updates":{"owner":"Hank"}})"; + auto result1 = UpdateNamespacePropertiesRequestFromJson(nlohmann::json::parse(json1)); + ASSERT_TRUE(result1.has_value()); + EXPECT_TRUE(result1.value().removals.empty()); + + // Removals is missing + std::string json2 = R"({"updates":{"owner":"Hank"}})"; + auto result2 = UpdateNamespacePropertiesRequestFromJson(nlohmann::json::parse(json2)); + ASSERT_TRUE(result2.has_value()); + EXPECT_TRUE(result2.value().removals.empty()); + + // Updates is null + std::string json3 = R"({"removals":["foo","bar"],"updates":null})"; + auto result3 = UpdateNamespacePropertiesRequestFromJson(nlohmann::json::parse(json3)); + ASSERT_TRUE(result3.has_value()); + EXPECT_TRUE(result3.value().updates.empty()); + + // All missing + std::string json4 = R"({})"; + auto result4 = UpdateNamespacePropertiesRequestFromJson(nlohmann::json::parse(json4)); + ASSERT_TRUE(result4.has_value()); + EXPECT_TRUE(result4.value().removals.empty()); + EXPECT_TRUE(result4.value().updates.empty()); +} + +TEST(UpdateNamespacePropertiesRequestTest, InvalidRequests) { + std::string json_wrong_removals_type = + R"({"removals":{"foo":"bar"},"updates":{"owner":"Hank"}})"; + auto result1 = UpdateNamespacePropertiesRequestFromJson( + nlohmann::json::parse(json_wrong_removals_type)); + EXPECT_FALSE(result1.has_value()); + EXPECT_THAT(result1, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result1.error().message, + "Failed to parse 'removals' from " + "{\"removals\":{\"foo\":\"bar\"},\"updates\":{\"owner\":\"Hank\"}}: " + "[json.exception.type_error.302] type must be array, but is object"); + + std::string json_wrong_updates_type = + R"({"removals":["foo","bar"],"updates":["owner"]})"; + auto result2 = UpdateNamespacePropertiesRequestFromJson( + nlohmann::json::parse(json_wrong_updates_type)); + EXPECT_FALSE(result2.has_value()); + EXPECT_THAT(result2, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result2.error().message, + "Failed to parse 'updates' from " + "{\"removals\":[\"foo\",\"bar\"],\"updates\":[\"owner\"]}: " + "[json.exception.type_error.302] type must be object, but is array"); +} + +struct UpdateNamespacePropertiesResponseParam { + std::string test_name; + std::string expected_json_str; + std::vector updated; + std::vector removed; + std::vector missing; +}; + +class UpdateNamespacePropertiesResponseTest + : public ::testing::TestWithParam { + protected: + void TestRoundTrip() { + const auto& param = GetParam(); + + UpdateNamespacePropertiesResponse original; + original.updated = param.updated; + original.removed = param.removed; + original.missing = param.missing; + + auto json = ToJson(original); + auto expected_json = nlohmann::json::parse(param.expected_json_str); + EXPECT_EQ(json, expected_json); + + auto result = UpdateNamespacePropertiesResponseFromJson(expected_json); + ASSERT_TRUE(result.has_value()) << result.error().message; + auto& parsed = result.value(); + + EXPECT_EQ(parsed, original); + } +}; + +TEST_P(UpdateNamespacePropertiesResponseTest, RoundTrip) { TestRoundTrip(); } + +INSTANTIATE_TEST_SUITE_P( + UpdateNamespacePropertiesResponseCases, UpdateNamespacePropertiesResponseTest, + ::testing::Values( + UpdateNamespacePropertiesResponseParam{ + .test_name = "FullResponse", + .expected_json_str = + R"({"removed":["foo"],"updated":["owner"],"missing":["bar"]})", + .updated = {"owner"}, + .removed = {"foo"}, + .missing = {"bar"}}, + UpdateNamespacePropertiesResponseParam{ + .test_name = "OnlyUpdated", + .expected_json_str = R"({"removed":[],"updated":["owner"]})", + .updated = {"owner"}, + .removed = {}, + .missing = {}}, + UpdateNamespacePropertiesResponseParam{ + .test_name = "OnlyRemoved", + .expected_json_str = R"({"removed":["foo"],"updated":[]})", + .updated = {}, + .removed = {"foo"}, + .missing = {}}, + UpdateNamespacePropertiesResponseParam{ + .test_name = "OnlyMissing", + .expected_json_str = R"({"removed":[],"updated":[],"missing":["bar"]})", + .updated = {}, + .removed = {}, + .missing = {"bar"}}, + UpdateNamespacePropertiesResponseParam{ + .test_name = "AllEmpty", + .expected_json_str = R"({"removed":[],"updated":[]})", + .updated = {}, + .removed = {}, + .missing = {}}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +TEST(UpdateNamespacePropertiesResponseTest, DeserializeWithoutDefaults) { + // Only updated, others missing + std::string json2 = R"({"updated":["owner"],"removed":[]})"; + auto result2 = UpdateNamespacePropertiesResponseFromJson(nlohmann::json::parse(json2)); + ASSERT_TRUE(result2.has_value()); + EXPECT_EQ(result2.value().updated, std::vector({"owner"})); + EXPECT_TRUE(result2.value().removed.empty()); + EXPECT_TRUE(result2.value().missing.empty()); + + // All missing + std::string json3 = R"({})"; + auto result3 = UpdateNamespacePropertiesResponseFromJson(nlohmann::json::parse(json3)); + EXPECT_FALSE(result3.has_value()); // updated and removed are required +} + +TEST(UpdateNamespacePropertiesResponseTest, InvalidResponses) { + std::string json_wrong_removed_type = + R"({"removed":{"foo":true},"updated":["owner"],"missing":["bar"]})"; + auto result1 = UpdateNamespacePropertiesResponseFromJson( + nlohmann::json::parse(json_wrong_removed_type)); + EXPECT_FALSE(result1.has_value()); + EXPECT_THAT(result1, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result1.error().message, + "Failed to parse 'removed' from " + "{\"missing\":[\"bar\"],\"removed\":{\"foo\":true},\"updated\":[\"owner\"]}: " + "[json.exception.type_error.302] type must be array, but is object"); + + std::string json_wrong_updated_type = R"({"updated":"owner","missing":["bar"]})"; + auto result2 = UpdateNamespacePropertiesResponseFromJson( + nlohmann::json::parse(json_wrong_updated_type)); + EXPECT_FALSE(result2.has_value()); + EXPECT_THAT(result2, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ( + result2.error().message, + "Failed to parse 'updated' from {\"missing\":[\"bar\"],\"updated\":\"owner\"}: " + "[json.exception.type_error.302] type must be array, but is string"); +} + +struct ListTablesResponseParam { + std::string test_name; + std::string expected_json_str; + std::vector identifiers; + std::string next_page_token; +}; + +class ListTablesResponseTest : public ::testing::TestWithParam { + protected: + void TestRoundTrip() { + const auto& param = GetParam(); + + ListTablesResponse original; + original.identifiers = param.identifiers; + original.next_page_token = param.next_page_token; + + auto json = ToJson(original); + auto expected_json = nlohmann::json::parse(param.expected_json_str); + EXPECT_EQ(json, expected_json); + + auto result = ListTablesResponseFromJson(expected_json); + ASSERT_TRUE(result.has_value()) << result.error().message; + auto& parsed = result.value(); + + EXPECT_EQ(parsed, original); + } +}; + +TEST_P(ListTablesResponseTest, RoundTrip) { TestRoundTrip(); } + +INSTANTIATE_TEST_SUITE_P( + ListTablesResponseCases, ListTablesResponseTest, + ::testing::Values( + ListTablesResponseParam{ + .test_name = "FullResponse", + .expected_json_str = + R"({"identifiers":[{"namespace":["accounting","tax"],"name":"paid"}]})", + .identifiers = {TableIdentifier{Namespace{{"accounting", "tax"}}, "paid"}}, + .next_page_token = ""}, + ListTablesResponseParam{.test_name = "EmptyIdentifiers", + .expected_json_str = R"({"identifiers":[]})", + .identifiers = {}, + .next_page_token = ""}, + ListTablesResponseParam{ + .test_name = "WithPageToken", + .expected_json_str = + R"({"identifiers":[{"namespace":["accounting","tax"],"name":"paid"}],"next-page-token":"token"})", + .identifiers = {TableIdentifier{Namespace{{"accounting", "tax"}}, "paid"}}, + .next_page_token = "token"}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +TEST(ListTablesResponseTest, InvalidResponses) { + std::string json_wrong_type = R"({"identifiers":"accounting%1Ftax"})"; + auto result1 = ListTablesResponseFromJson(nlohmann::json::parse(json_wrong_type)); + EXPECT_FALSE(result1.has_value()); + EXPECT_THAT(result1, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result1.error().message, "Missing 'name' in \"accounting%1Ftax\""); + + std::string json_empty = R"({})"; + auto result2 = ListTablesResponseFromJson(nlohmann::json::parse(json_empty)); + EXPECT_FALSE(result2.has_value()); + EXPECT_THAT(result2, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result2.error().message, "Missing 'identifiers' in {}"); + + std::string json_invalid_identifier = + R"({"identifiers":[{"namespace":"accounting.tax","name":"paid"}]})"; + auto result3 = + ListTablesResponseFromJson(nlohmann::json::parse(json_invalid_identifier)); + EXPECT_FALSE(result3.has_value()); + EXPECT_THAT(result3, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result3.error().message, + "Failed to parse 'namespace' from " + "{\"name\":\"paid\",\"namespace\":\"accounting.tax\"}: " + "[json.exception.type_error.302] type must be array, but is string"); +} + +struct RenameTableRequestParam { + std::string test_name; + std::string expected_json_str; + TableIdentifier source; + TableIdentifier destination; +}; + +class RenameTableRequestTest : public ::testing::TestWithParam { + protected: + void TestRoundTrip() { + const auto& param = GetParam(); + + RenameTableRequest original; + original.source = param.source; + original.destination = param.destination; + + auto json = ToJson(original); + auto expected_json = nlohmann::json::parse(param.expected_json_str); + EXPECT_EQ(json, expected_json); + + auto result = RenameTableRequestFromJson(expected_json); + ASSERT_TRUE(result.has_value()) << result.error().message; + auto& parsed = result.value(); + + EXPECT_EQ(parsed, original); + } +}; + +TEST_P(RenameTableRequestTest, RoundTrip) { TestRoundTrip(); } + +INSTANTIATE_TEST_SUITE_P( + RenameTableRequestCases, RenameTableRequestTest, + ::testing::Values(RenameTableRequestParam{ + .test_name = "FullRequest", + .expected_json_str = + R"({"source":{"namespace":["accounting","tax"],"name":"paid"},"destination":{"namespace":["accounting","tax"],"name":"paid_2022"}})", + .source = TableIdentifier{Namespace{{"accounting", "tax"}}, "paid"}, + .destination = TableIdentifier{Namespace{{"accounting", "tax"}}, "paid_2022"}}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +TEST(RenameTableRequestTest, InvalidRequests) { + std::string json_source_null_name = + R"({"source":{"namespace":["accounting","tax"],"name":null},"destination":{"namespace":["accounting","tax"],"name":"paid_2022"}})"; + auto result1 = RenameTableRequestFromJson(nlohmann::json::parse(json_source_null_name)); + EXPECT_FALSE(result1.has_value()); + EXPECT_THAT(result1, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result1.error().message, + "Missing 'name' in {\"name\":null,\"namespace\":[\"accounting\",\"tax\"]}"); + + std::string json_dest_null_name = + R"({"source":{"namespace":["accounting","tax"],"name":"paid"},"destination":{"namespace":["accounting","tax"],"name":null}})"; + auto result2 = RenameTableRequestFromJson(nlohmann::json::parse(json_dest_null_name)); + EXPECT_FALSE(result2.has_value()); + EXPECT_THAT(result2, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result2.error().message, + "Missing 'name' in {\"name\":null,\"namespace\":[\"accounting\",\"tax\"]}"); + + std::string json_empty = R"({})"; + auto result3 = RenameTableRequestFromJson(nlohmann::json::parse(json_empty)); + EXPECT_FALSE(result3.has_value()); + EXPECT_THAT(result3, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result3.error().message, "Missing 'source' in {}"); +} + +struct RegisterTableRequestParam { + std::string test_name; + std::string expected_json_str; + std::string name; + std::string metadata_location; + bool overwrite; +}; + +class RegisterTableRequestTest + : public ::testing::TestWithParam { + protected: + void TestRoundTrip() { + const auto& param = GetParam(); + + RegisterTableRequest original; + original.name = param.name; + original.metadata_location = param.metadata_location; + original.overwrite = param.overwrite; + + auto json = ToJson(original); + auto expected_json = nlohmann::json::parse(param.expected_json_str); + EXPECT_EQ(json, expected_json); + + auto result = RegisterTableRequestFromJson(expected_json); + ASSERT_TRUE(result.has_value()) << result.error().message; + auto& parsed = result.value(); + + EXPECT_EQ(parsed, original); + } +}; + +TEST_P(RegisterTableRequestTest, RoundTrip) { TestRoundTrip(); } + +INSTANTIATE_TEST_SUITE_P( + RegisterTableRequestCases, RegisterTableRequestTest, + ::testing::Values( + RegisterTableRequestParam{ + .test_name = "WithOverwriteTrue", + .expected_json_str = + R"({"name":"table1","metadata-location":"s3://bucket/metadata.json","overwrite":true})", + .name = "table1", + .metadata_location = "s3://bucket/metadata.json", + .overwrite = true}, + RegisterTableRequestParam{ + .test_name = "WithoutOverwrite", + .expected_json_str = + R"({"name":"table1","metadata-location":"s3://bucket/metadata.json"})", + .name = "table1", + .metadata_location = "s3://bucket/metadata.json", + .overwrite = false}), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +TEST(RegisterTableRequestTest, DeserializeWithoutDefaults) { + // Overwrite missing (defaults to false) + std::string json1 = + R"({"name":"table1","metadata-location":"s3://bucket/metadata.json"})"; + auto result1 = RegisterTableRequestFromJson(nlohmann::json::parse(json1)); + ASSERT_TRUE(result1.has_value()); + EXPECT_FALSE(result1.value().overwrite); +} + +TEST(RegisterTableRequestTest, InvalidRequests) { + std::string json_missing_name = R"({"metadata-location":"s3://bucket/metadata.json"})"; + auto result1 = RegisterTableRequestFromJson(nlohmann::json::parse(json_missing_name)); + EXPECT_FALSE(result1.has_value()); + EXPECT_THAT(result1, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result1.error().message, + "Missing 'name' in {\"metadata-location\":\"s3://bucket/metadata.json\"}"); + + std::string json_missing_location = R"({"name":"table1"})"; + auto result2 = + RegisterTableRequestFromJson(nlohmann::json::parse(json_missing_location)); + EXPECT_FALSE(result2.has_value()); + EXPECT_THAT(result2, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result2.error().message, + "Missing 'metadata-location' in {\"name\":\"table1\"}"); + + std::string json_empty = R"({})"; + auto result3 = RegisterTableRequestFromJson(nlohmann::json::parse(json_empty)); + EXPECT_FALSE(result3.has_value()); + EXPECT_THAT(result3, IsError(ErrorKind::kJsonParseError)); + EXPECT_EQ(result3.error().message, "Missing 'name' in {}"); +} + +} // namespace iceberg::rest diff --git a/src/iceberg/util/json_util_internal.h b/src/iceberg/util/json_util_internal.h index 81d17b04b..6205ad18c 100644 --- a/src/iceberg/util/json_util_internal.h +++ b/src/iceberg/util/json_util_internal.h @@ -44,6 +44,15 @@ inline std::string SafeDumpJson(const nlohmann::json& json) { nlohmann::detail::error_handler_t::ignore); } +template +Result GetTypedJsonValue(const nlohmann::json& json) { + try { + return json.get(); + } catch (const std::exception& ex) { + return JsonParseError("Failed to parse {}: {}", SafeDumpJson(json), ex.what()); + } +} + template Result GetJsonValueImpl(const nlohmann::json& json, std::string_view key) { try {