From 8444ef8f38a1e7d0fee9b79b7c46faca8cafb0f1 Mon Sep 17 00:00:00 2001 From: Li Feiyang Date: Fri, 24 Oct 2025 18:56:00 +0800 Subject: [PATCH 1/4] feat: Implement JSON serialization for REST Catalog API types --- src/iceberg/catalog/rest/CMakeLists.txt | 2 +- src/iceberg/catalog/rest/json_internal.cc | 433 +++++++++++++ src/iceberg/catalog/rest/json_internal.h | 166 +++++ src/iceberg/test/CMakeLists.txt | 3 +- src/iceberg/test/rest_json_internal_test.cc | 664 ++++++++++++++++++++ 5 files changed, 1266 insertions(+), 2 deletions(-) create mode 100644 src/iceberg/catalog/rest/json_internal.cc create mode 100644 src/iceberg/catalog/rest/json_internal.h create mode 100644 src/iceberg/test/rest_json_internal_test.cc 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..49b00e419 --- /dev/null +++ b/src/iceberg/catalog/rest/json_internal.cc @@ -0,0 +1,433 @@ +/* + * 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/json_internal.h" +#include "iceberg/partition_spec.h" +#include "iceberg/schema.h" +#include "iceberg/sort_order.h" +#include "iceberg/table_metadata.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"; + +using MapType = std::unordered_map; + +/// Helper function to convert TableIdentifier to JSON +nlohmann::json ToJson(const TableIdentifier& identifier) { + nlohmann::json json; + json[kNamespace] = identifier.ns.levels; + json[kName] = identifier.name; + return json; +} + +/// Helper function to parse TableIdentifier from JSON +Result TableIdentifierFromJson(const nlohmann::json& json) { + TableIdentifier identifier; + + ICEBERG_ASSIGN_OR_RAISE(identifier.ns.levels, + GetJsonValue>(json, kNamespace)); + ICEBERG_ASSIGN_OR_RAISE(identifier.name, GetJsonValue(json, kName)); + + return identifier; +} + +} // namespace + +// CreateNamespaceRequest +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(auto levels, + GetJsonValue>(json, kNamespace)); + request.namespace_.levels = std::move(levels); + + ICEBERG_ASSIGN_OR_RAISE(request.properties, + GetJsonValueOrDefault(json, kProperties)); + + return request; +} + +// UpdateNamespacePropertiesRequest +nlohmann::json ToJson(const UpdateNamespacePropertiesRequest& request) { + 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; +} + +// CreateTableRequest +nlohmann::json ToJson(const CreateTableRequest& request) { + nlohmann::json json; + + json[kName] = request.name; + + if (!request.location.empty()) { + json[kLocation] = request.location; + } + + json[kSchema] = iceberg::ToJson(*request.schema); + + if (request.partition_spec) { + json[kPartitionSpec] = iceberg::ToJson(*request.partition_spec); + } + + if (request.write_order) { + json[kWriteOrder] = iceberg::ToJson(*request.write_order); + } + + SetOptionalField(json, kStageCreate, request.stage_create); + + if (!request.properties.empty()) { + json[kProperties] = request.properties; + } + + return json; +} + +Result CreateTableRequestFromJson(const nlohmann::json& json) { + CreateTableRequest request; + + ICEBERG_ASSIGN_OR_RAISE(request.name, GetJsonValue(json, kName)); + + ICEBERG_ASSIGN_OR_RAISE(auto location, + GetJsonValueOptional(json, kLocation)); + request.location = location.value_or(""); + + ICEBERG_ASSIGN_OR_RAISE(auto schema_json, GetJsonValue(json, kSchema)); + ICEBERG_ASSIGN_OR_RAISE(auto schema_ptr, iceberg::SchemaFromJson(schema_json)); + request.schema = std::move(schema_ptr); + + if (json.contains(kPartitionSpec)) { + ICEBERG_ASSIGN_OR_RAISE(auto partition_spec_json, + GetJsonValue(json, kPartitionSpec)); + ICEBERG_ASSIGN_OR_RAISE( + request.partition_spec, + iceberg::PartitionSpecFromJson(request.schema, partition_spec_json)); + } else { + request.partition_spec = nullptr; + } + + if (json.contains(kWriteOrder)) { + ICEBERG_ASSIGN_OR_RAISE(auto write_order_json, + GetJsonValue(json, kWriteOrder)); + ICEBERG_ASSIGN_OR_RAISE(request.write_order, + iceberg::SortOrderFromJson(write_order_json)); + } else { + request.write_order = nullptr; + } + + ICEBERG_ASSIGN_OR_RAISE(request.stage_create, + GetJsonValueOptional(json, kStageCreate)); + + ICEBERG_ASSIGN_OR_RAISE(request.properties, + GetJsonValueOrDefault(json, kProperties)); + + return request; +} + +// RegisterTableRequest +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)); + + // Default to false if not present + ICEBERG_ASSIGN_OR_RAISE(auto overwrite_opt, + GetJsonValueOptional(json, kOverwrite)); + request.overwrite = overwrite_opt.value_or(false); + + return request; +} + +// RenameTableRequest +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.has_value() && !result.metadata_location->empty()) { + json[kMetadataLocation] = result.metadata_location.value(); + } + + json[kMetadata] = iceberg::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(auto metadata_location, + GetJsonValueOptional(json, kMetadataLocation)); + result.metadata_location = metadata_location; + + ICEBERG_ASSIGN_OR_RAISE(auto metadata_json, + GetJsonValue(json, kMetadata)); + ICEBERG_ASSIGN_OR_RAISE(auto metadata_ptr, + iceberg::TableMetadataFromJson(metadata_json)); + result.metadata = std::move(metadata_ptr); + + ICEBERG_ASSIGN_OR_RAISE(result.config, GetJsonValueOrDefault(json, kConfig)); + + return result; +} + +// ListNamespacesResponse +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(ns.levels); + } + json[kNamespaces] = std::move(namespaces); + + return json; +} + +Result ListNamespacesResponseFromJson( + const nlohmann::json& json) { + ListNamespacesResponse response; + + ICEBERG_ASSIGN_OR_RAISE(auto next_page_token, + GetJsonValueOptional(json, kNextPageToken)); + response.next_page_token = next_page_token.value_or(""); + + ICEBERG_ASSIGN_OR_RAISE( + auto namespace_levels, + GetJsonValue>>(json, kNamespaces)); + response.namespaces.reserve(namespace_levels.size()); + for (auto& levels : namespace_levels) { + response.namespaces.push_back(Namespace{.levels = std::move(levels)}); + } + + return response; +} + +// CreateNamespaceResponse +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(auto levels, + GetJsonValue>(json, kNamespace)); + response.namespace_.levels = std::move(levels); + + ICEBERG_ASSIGN_OR_RAISE(response.properties, + GetJsonValueOrDefault(json, kProperties)); + + return response; +} + +// GetNamespaceResponse +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(auto levels, + GetJsonValue>(json, kNamespace)); + response.namespace_.levels = std::move(levels); + + ICEBERG_ASSIGN_OR_RAISE(response.properties, + GetJsonValueOrDefault(json, kProperties)); + + return response; +} + +// UpdateNamespacePropertiesResponse +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; +} + +// ListTablesResponse +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(auto next_page_token, + GetJsonValueOptional(json, kNextPageToken)); + response.next_page_token = next_page_token.value_or(""); + + 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..08aa6cd23 --- /dev/null +++ b/src/iceberg/catalog/rest/json_internal.h @@ -0,0 +1,166 @@ +/* + * 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 `CreateNamespaceRequest` object to JSON. +/// +/// \param request The `CreateNamespaceRequest` object to be serialized. +/// \return A JSON object representing the `CreateNamespaceRequest`. +nlohmann::json ToJson(const CreateNamespaceRequest& request); + +/// \brief Deserializes a JSON object into a `CreateNamespaceRequest` object. +/// +/// \param json The JSON object representing a `CreateNamespaceRequest`. +/// \return A `CreateNamespaceRequest` object or an error if the conversion fails. +Result CreateNamespaceRequestFromJson(const nlohmann::json& json); + +/// \brief Serializes an `UpdateNamespacePropertiesRequest` object to JSON. +/// +/// \param request The `UpdateNamespacePropertiesRequest` object to be serialized. +/// \return A JSON object representing the `UpdateNamespacePropertiesRequest`. +nlohmann::json ToJson(const UpdateNamespacePropertiesRequest& request); + +/// \brief Deserializes a JSON object into an `UpdateNamespacePropertiesRequest` object. +/// +/// \param json The JSON object representing an `UpdateNamespacePropertiesRequest`. +/// \return An `UpdateNamespacePropertiesRequest` object or an error if the conversion +/// fails. +Result UpdateNamespacePropertiesRequestFromJson( + const nlohmann::json& json); + +/// \brief Serializes a `CreateTableRequest` object to JSON. +/// +/// \param request The `CreateTableRequest` object to be serialized. +/// \return A JSON object representing the `CreateTableRequest`. +nlohmann::json ToJson(const CreateTableRequest& request); + +/// \brief Deserializes a JSON object into a `CreateTableRequest` object. +/// +/// \param json The JSON object representing a `CreateTableRequest`. +/// \return A `CreateTableRequest` object or an error if the conversion fails. +Result CreateTableRequestFromJson(const nlohmann::json& json); + +/// \brief Serializes a `RegisterTableRequest` object to JSON. +/// +/// \param request The `RegisterTableRequest` object to be serialized. +/// \return A JSON object representing the `RegisterTableRequest`. +nlohmann::json ToJson(const RegisterTableRequest& request); + +/// \brief Deserializes a JSON object into a `RegisterTableRequest` object. +/// +/// \param json The JSON object representing a `RegisterTableRequest`. +/// \return A `RegisterTableRequest` object or an error if the conversion fails. +Result RegisterTableRequestFromJson(const nlohmann::json& json); + +/// \brief Serializes a `RenameTableRequest` object to JSON. +/// +/// \param request The `RenameTableRequest` object to be serialized. +/// \return A JSON object representing the `RenameTableRequest`. +nlohmann::json ToJson(const RenameTableRequest& request); + +/// \brief Deserializes a JSON object into a `RenameTableRequest` object. +/// +/// \param json The JSON object representing a `RenameTableRequest`. +/// \return A `RenameTableRequest` object or an error if the conversion fails. +Result RenameTableRequestFromJson(const nlohmann::json& json); + +/// \brief Serializes a `LoadTableResult` object to JSON. +/// +/// \param result The `LoadTableResult` object to be serialized. +/// \return A JSON object representing the `LoadTableResult`. +nlohmann::json ToJson(const LoadTableResult& result); + +/// \brief Deserializes a JSON object into a `LoadTableResult` object. +/// +/// \param json The JSON object representing a `LoadTableResult`. +/// \return A `LoadTableResult` object or an error if the conversion fails. +Result LoadTableResultFromJson(const nlohmann::json& json); + +/// \brief Serializes a `ListNamespacesResponse` object to JSON. +/// +/// \param response The `ListNamespacesResponse` object to be serialized. +/// \return A JSON object representing the `ListNamespacesResponse`. +nlohmann::json ToJson(const ListNamespacesResponse& response); + +/// \brief Deserializes a JSON object into a `ListNamespacesResponse` object. +/// +/// \param json The JSON object representing a `ListNamespacesResponse`. +/// \return A `ListNamespacesResponse` object or an error if the conversion fails. +Result ListNamespacesResponseFromJson(const nlohmann::json& json); + +/// \brief Serializes a `CreateNamespaceResponse` object to JSON. +/// +/// \param response The `CreateNamespaceResponse` object to be serialized. +/// \return A JSON object representing the `CreateNamespaceResponse`. +nlohmann::json ToJson(const CreateNamespaceResponse& response); + +/// \brief Deserializes a JSON object into a `CreateNamespaceResponse` object. +/// +/// \param json The JSON object representing a `CreateNamespaceResponse`. +/// \return A `CreateNamespaceResponse` object or an error if the conversion fails. +Result CreateNamespaceResponseFromJson( + const nlohmann::json& json); + +/// \brief Serializes a `GetNamespaceResponse` object to JSON. +/// +/// \param response The `GetNamespaceResponse` object to be serialized. +/// \return A JSON object representing the `GetNamespaceResponse`. +nlohmann::json ToJson(const GetNamespaceResponse& response); + +/// \brief Deserializes a JSON object into a `GetNamespaceResponse` object. +/// +/// \param json The JSON object representing a `GetNamespaceResponse`. +/// \return A `GetNamespaceResponse` object or an error if the conversion fails. +Result GetNamespaceResponseFromJson(const nlohmann::json& json); + +/// \brief Serializes an `UpdateNamespacePropertiesResponse` object to JSON. +/// +/// \param response The `UpdateNamespacePropertiesResponse` object to be serialized. +/// \return A JSON object representing the `UpdateNamespacePropertiesResponse`. +nlohmann::json ToJson(const UpdateNamespacePropertiesResponse& response); + +/// \brief Deserializes a JSON object into an `UpdateNamespacePropertiesResponse` object. +/// +/// \param json The JSON object representing an `UpdateNamespacePropertiesResponse`. +/// \return An `UpdateNamespacePropertiesResponse` object or an error if the conversion +/// fails. +Result UpdateNamespacePropertiesResponseFromJson( + const nlohmann::json& json); + +/// \brief Serializes a `ListTablesResponse` object to JSON. +/// +/// \param response The `ListTablesResponse` object to be serialized. +/// \return A JSON object representing the `ListTablesResponse`. +nlohmann::json ToJson(const ListTablesResponse& response); + +/// \brief Deserializes a JSON object into a `ListTablesResponse` object. +/// +/// \param json The JSON object representing a `ListTablesResponse`. +/// \return A `ListTablesResponse` object or an error if the conversion fails. +Result ListTablesResponseFromJson(const nlohmann::json& json); + +} // namespace iceberg::rest 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/rest_json_internal_test.cc b/src/iceberg/test/rest_json_internal_test.cc new file mode 100644 index 000000000..510a8d49e --- /dev/null +++ b/src/iceberg/test/rest_json_internal_test.cc @@ -0,0 +1,664 @@ +/* + * 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/partition_spec.h" +#include "iceberg/schema.h" +#include "iceberg/snapshot.h" +#include "iceberg/sort_order.h" +#include "iceberg/table_identifier.h" +#include "iceberg/table_metadata.h" +#include "iceberg/transform.h" +#include "matchers.h" + +namespace iceberg::rest { + +namespace { + +// Helper templates for type-specific deserialization +template +Result FromJsonHelper(const nlohmann::json& json); + +template <> +Result FromJsonHelper( + const nlohmann::json& j) { + return CreateNamespaceRequestFromJson(j); +} + +template <> +Result FromJsonHelper( + const nlohmann::json& j) { + return UpdateNamespacePropertiesRequestFromJson(j); +} + +template <> +Result FromJsonHelper(const nlohmann::json& j) { + return CreateTableRequestFromJson(j); +} + +template <> +Result FromJsonHelper( + const nlohmann::json& j) { + return RegisterTableRequestFromJson(j); +} + +template <> +Result FromJsonHelper(const nlohmann::json& j) { + return RenameTableRequestFromJson(j); +} + +template <> +Result FromJsonHelper(const nlohmann::json& j) { + return LoadTableResultFromJson(j); +} + +template <> +Result FromJsonHelper( + const nlohmann::json& j) { + return ListNamespacesResponseFromJson(j); +} + +template <> +Result FromJsonHelper( + const nlohmann::json& j) { + return CreateNamespaceResponseFromJson(j); +} + +template <> +Result FromJsonHelper( + const nlohmann::json& j) { + return GetNamespaceResponseFromJson(j); +} + +template <> +Result +FromJsonHelper(const nlohmann::json& j) { + return UpdateNamespacePropertiesResponseFromJson(j); +} + +template <> +Result FromJsonHelper(const nlohmann::json& j) { + return ListTablesResponseFromJson(j); +} + +// Test helper functions +template +void TestJsonRoundTrip(const T& obj) { + auto j = ToJson(obj); + auto parsed = FromJsonHelper(j); + ASSERT_TRUE(parsed.has_value()) << parsed.error().message; + auto j2 = ToJson(parsed.value()); + EXPECT_EQ(j, j2) << "Round-trip JSON mismatch."; +} + +template +void TestJsonConversion(const T& obj, const nlohmann::json& expected_json) { + auto got = ToJson(obj); + EXPECT_EQ(expected_json, got) << "JSON conversion mismatch."; + + auto parsed = FromJsonHelper(expected_json); + ASSERT_TRUE(parsed.has_value()) << parsed.error().message; + auto back = ToJson(parsed.value()); + EXPECT_EQ(expected_json, back) << "JSON conversion mismatch."; +} + +template +void TestMissingRequiredField(const nlohmann::json& invalid_json, + const std::string& expected_field_name) { + auto result = FromJsonHelper(invalid_json); + EXPECT_FALSE(result.has_value()); + EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError)); + EXPECT_THAT(result, HasErrorMessage("Missing '" + expected_field_name + "'")); +} + +template +void TestUnknownFieldIgnored(const nlohmann::json& minimal_valid) { + auto j = minimal_valid; + j["__unknown__"] = {{"nested", 1}}; + auto parsed = FromJsonHelper(j); + ASSERT_TRUE(parsed.has_value()) << parsed.error().message; + auto out = ToJson(parsed.value()); + EXPECT_FALSE(out.contains("__unknown__")); +} + +std::shared_ptr CreateTableMetadata() { + std::vector schema_fields{ + SchemaField(1, "id", iceberg::int64(), false), + SchemaField(2, "name", iceberg::string(), true), + SchemaField(3, "ts", iceberg::timestamp(), false), + }; + auto schema = std::make_shared(std::move(schema_fields), /*schema_id=*/1); + + auto spec = std::make_shared( + schema, 1, + std::vector{PartitionField(3, 1000, "ts_day", Transform::Day())}); + + auto order = std::make_shared( + 1, std::vector{SortField(1, Transform::Identity(), + SortDirection::kAscending, NullOrder::kFirst)}); + + return std::make_shared(TableMetadata{ + .format_version = 2, + .table_uuid = "test-uuid-12345", + .location = "s3://bucket/warehouse/table", + .last_sequence_number = 42, + .last_updated_ms = TimePointMsFromUnixMs(1700000000000).value(), + .last_column_id = 3, + .schemas = {schema}, + .current_schema_id = 1, + .partition_specs = {spec}, + .default_spec_id = 1, + .last_partition_id = 1000, + .properties = {{"engine.version", "1.2.3"}, {"write.format.default", "parquet"}}, + .current_snapshot_id = 1234567890, + .snapshots = {std::make_shared( + Snapshot{.snapshot_id = 1234567890, + .sequence_number = 1, + .timestamp_ms = TimePointMsFromUnixMs(1699999999999).value(), + .manifest_list = "s3://bucket/metadata/snap-1234567890.avro", + .summary = {{"operation", "append"}, {"added-files", "10"}}})}, + .snapshot_log = {}, + .metadata_log = {}, + .sort_orders = {order}, + .default_sort_order_id = 1, + .refs = {}, + .statistics = {}, + .partition_statistics = {}, + .next_row_id = 100}); +} + +TableIdentifier CreateTableIdentifier(std::vector ns, std::string name) { + return TableIdentifier{.ns = Namespace{std::move(ns)}, .name = std::move(name)}; +} + +} // namespace + +TEST(JsonRestNewTest, CreateNamespaceRequestFull) { + CreateNamespaceRequest req{ + .namespace_ = Namespace{.levels = {"db1", "schema1"}}, + .properties = std::unordered_map{ + {"owner", "user1"}, {"location", "/warehouse/db1/schema1"}}}; + + nlohmann::json golden = R"({ + "namespace": ["db1","schema1"], + "properties": {"owner":"user1","location":"/warehouse/db1/schema1"} + })"_json; + + TestJsonConversion(req, golden); + TestJsonRoundTrip(req); +} + +TEST(JsonRestNewTest, CreateNamespaceRequestMinimal) { + CreateNamespaceRequest req{.namespace_ = Namespace{.levels = {"db_only"}}, + .properties = {}}; + + nlohmann::json golden = R"({ + "namespace": ["db_only"] + })"_json; + + TestJsonConversion(req, golden); +} + +TEST(JsonRestNewTest, CreateNamespaceRequestMissingNamespace) { + nlohmann::json invalid = R"({ + "properties": {"key": "value"} + })"_json; + + TestMissingRequiredField(invalid, "namespace"); +} + +TEST(JsonRestNewTest, CreateNamespaceRequestIgnoreUnknown) { + nlohmann::json minimal = R"({"namespace":["db"]})"_json; + TestUnknownFieldIgnored(minimal); +} + +TEST(JsonRestNewTest, UpdateNamespacePropertiesRequestBoth) { + UpdateNamespacePropertiesRequest req{ + .removals = std::vector{"k1", "k2"}, + .updates = std::unordered_map{{"a", "b"}}}; + + nlohmann::json golden = R"({ + "removals": ["k1","k2"], + "updates": {"a":"b"} + })"_json; + + TestJsonConversion(req, golden); + TestJsonRoundTrip(req); +} + +TEST(JsonRestNewTest, UpdateNamespacePropertiesRequestRemovalsOnly) { + UpdateNamespacePropertiesRequest req{.removals = std::vector{"k"}, + .updates = {}}; + + nlohmann::json golden = R"({ + "removals": ["k"] + })"_json; + + TestJsonConversion(req, golden); +} + +TEST(JsonRestNewTest, UpdateNamespacePropertiesRequestUpdatesOnly) { + UpdateNamespacePropertiesRequest req{ + .removals = {}, + .updates = std::unordered_map{{"x", "y"}}}; + + nlohmann::json golden = R"({ + "updates": {"x":"y"} + })"_json; + + TestJsonConversion(req, golden); +} + +TEST(JsonRestNewTest, UpdateNamespacePropertiesRequestEmpty) { + UpdateNamespacePropertiesRequest req{.removals = {}, .updates = {}}; + + nlohmann::json golden = R"({})"_json; + + TestJsonConversion(req, golden); +} + +TEST(JsonRestNewTest, CreateTableRequestFull) { + auto schema = std::make_shared( + std::vector{SchemaField(1, "id", iceberg::int64(), false), + SchemaField(2, "ts", iceberg::timestamp(), false), + SchemaField(3, "data", iceberg::string(), true)}, + 0); + + auto partition_spec = + std::make_shared(schema, 1, + std::vector{PartitionField( + 2, 1000, "ts_month", Transform::Month())}); + + SortField sort_field(1, Transform::Identity(), SortDirection::kAscending, + NullOrder::kFirst); + auto write_order = std::make_shared(1, std::vector{sort_field}); + + CreateTableRequest request{ + .name = "test_table", + .location = "s3://bucket/warehouse/test_table", + .schema = schema, + .partition_spec = partition_spec, + .write_order = write_order, + .stage_create = true, + .properties = std::unordered_map{ + {"write.format.default", "parquet"}, {"commit.retry.num-retries", "10"}}}; + + TestJsonRoundTrip(request); +} + +TEST(JsonRestNewTest, CreateTableRequestMinimal) { + auto schema = std::make_shared( + std::vector{SchemaField(1, "id", iceberg::int64(), false)}, 0); + + CreateTableRequest request{.name = "minimal_table", + .location = "", + .schema = schema, + .partition_spec = nullptr, + .write_order = nullptr, + .stage_create = std::nullopt, + .properties = {}}; + + TestJsonRoundTrip(request); + + auto json = ToJson(request); + EXPECT_EQ(json["name"], "minimal_table"); + EXPECT_FALSE(json.contains("location")); + EXPECT_TRUE(json.contains("schema")); + EXPECT_FALSE(json.contains("partition-spec")); + EXPECT_FALSE(json.contains("write-order")); + EXPECT_FALSE(json.contains("stage-create")); + EXPECT_FALSE(json.contains("properties")); +} + +TEST(JsonRestNewTest, CreateTableRequestMissingRequiredFields) { + nlohmann::json invalid_json = R"({ + "location": "/tmp/test" + })"_json; + + TestMissingRequiredField(invalid_json, "name"); + + invalid_json = R"({ + "name": "test_table" + })"_json; + + TestMissingRequiredField(invalid_json, "schema"); +} + +TEST(JsonRestNewTest, RegisterTableRequestWithOverwriteTrue) { + RegisterTableRequest r1{ + .name = "t", .metadata_location = "s3://m/v1.json", .overwrite = true}; + + nlohmann::json g1 = R"({ + "name":"t", + "metadata-location":"s3://m/v1.json", + "overwrite": true + })"_json; + + TestJsonConversion(r1, g1); + TestJsonRoundTrip(r1); +} + +TEST(JsonRestNewTest, RegisterTableRequestWithOverwriteFalse) { + RegisterTableRequest r2{ + .name = "t2", .metadata_location = "/tmp/m.json", .overwrite = false}; + + nlohmann::json g2 = R"({ + "name":"t2", + "metadata-location":"/tmp/m.json" + })"_json; + + TestJsonConversion(r2, g2); +} + +TEST(JsonRestNewTest, RegisterTableRequestMissingFields) { + TestMissingRequiredField( + R"({"metadata-location":"s3://m/v1.json"})"_json, "name"); + TestMissingRequiredField(R"({"name":"t"})"_json, + "metadata-location"); +} + +TEST(JsonRestNewTest, RenameTableRequestBasic) { + RenameTableRequest req{.source = CreateTableIdentifier({"old"}, "t0"), + .destination = CreateTableIdentifier({"new", "s"}, "t1")}; + + nlohmann::json golden = R"({ + "source": {"namespace":["old"], "name":"t0"}, + "destination": {"namespace":["new","s"], "name":"t1"} + })"_json; + + TestJsonConversion(req, golden); + TestJsonRoundTrip(req); +} + +TEST(JsonRestNewTest, RenameTableRequestDecodeWithUnknownField) { + nlohmann::json golden = R"({ + "source": {"namespace":["a"], "name":"b"}, + "destination": {"namespace":["x","y"], "name":"z"}, + "__extra__": true + })"_json; + + auto parsed = RenameTableRequestFromJson(golden); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->source.ns.levels, (std::vector{"a"})); + EXPECT_EQ(parsed->destination.ns.levels, (std::vector{"x", "y"})); +} + +TEST(JsonRestNewTest, RenameTableRequestInvalidNested) { + nlohmann::json invalid = R"({ + "source": { + "namespace": "should be array", + "name": "table" + }, + "destination": { + "namespace": ["db"], + "name": "new_table" + } + })"_json; + + auto result = RenameTableRequestFromJson(invalid); + EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError)); +} + +TEST(JsonRestNewTest, LoadTableResultFull) { + LoadTableResult r{.metadata_location = "s3://bucket/metadata/v1.json", + .metadata = CreateTableMetadata(), + .config = std::unordered_map{ + {"catalog-impl", "org.apache.iceberg.rest.RESTCatalog"}, + {"warehouse", "s3://bucket/warehouse"}}}; + + TestJsonRoundTrip(r); + + auto j = ToJson(r); + EXPECT_TRUE(j.contains("metadata-location")); + EXPECT_TRUE(j.contains("metadata")); + EXPECT_TRUE(j.contains("config")); + EXPECT_EQ(j["metadata"]["table-uuid"], "test-uuid-12345"); +} + +TEST(JsonRestNewTest, LoadTableResultMinimal) { + LoadTableResult r{ + .metadata_location = std::nullopt, .metadata = CreateTableMetadata(), .config = {}}; + + TestJsonRoundTrip(r); + + auto j = ToJson(r); + EXPECT_FALSE(j.contains("metadata-location")); + EXPECT_TRUE(j.contains("metadata")); + EXPECT_FALSE(j.contains("config")); +} + +TEST(JsonRestNewTest, LoadTableResultWithEmptyMetadataLocation) { + LoadTableResult r{ + .metadata_location = "", .metadata = CreateTableMetadata(), .config = {}}; + + auto j = ToJson(r); + EXPECT_FALSE(j.contains("metadata-location")); +} + +TEST(JsonRestNewTest, ListNamespacesResponseWithPageToken) { + ListNamespacesResponse r{ + .next_page_token = "token123", + .namespaces = {Namespace{.levels = {"db1"}}, Namespace{.levels = {"db2", "s1"}}, + Namespace{.levels = {"db3", "s2", "sub"}}}}; + + nlohmann::json golden = R"({ + "next-page-token": "token123", + "namespaces": [ + ["db1"], ["db2","s1"], ["db3","s2","sub"] + ] + })"_json; + + TestJsonConversion(r, golden); + TestJsonRoundTrip(r); +} + +TEST(JsonRestNewTest, ListNamespacesResponseWithoutPageToken) { + ListNamespacesResponse r{ + .next_page_token = "", + .namespaces = {Namespace{.levels = {"db1"}}, Namespace{.levels = {"db2"}}}}; + + nlohmann::json golden = R"({ + "namespaces": [["db1"], ["db2"]] + })"_json; + + TestJsonConversion(r, golden); +} + +TEST(JsonRestNewTest, CreateNamespaceResponseFull) { + CreateNamespaceResponse resp{ + .namespace_ = Namespace{.levels = {"db1", "s1"}}, + .properties = std::unordered_map{{"created", "true"}}}; + + nlohmann::json golden = R"({ + "namespace": ["db1","s1"], + "properties": {"created":"true"} + })"_json; + + TestJsonConversion(resp, golden); + TestJsonRoundTrip(resp); +} + +TEST(JsonRestNewTest, CreateNamespaceResponseMinimal) { + CreateNamespaceResponse resp{.namespace_ = Namespace{.levels = {"db1", "s1"}}, + .properties = {}}; + + nlohmann::json golden = R"({"namespace":["db1","s1"]})"_json; + + TestJsonConversion(resp, golden); +} + +TEST(JsonRestNewTest, CreateNamespaceResponseIgnoreUnknown) { + nlohmann::json minimal = R"({"namespace":["db","s"]})"_json; + TestUnknownFieldIgnored(minimal); +} + +TEST(JsonRestNewTest, GetNamespaceResponseFull) { + GetNamespaceResponse r{.namespace_ = Namespace{.levels = {"prod", "analytics"}}, + .properties = std::unordered_map{ + {"owner", "team-analytics"}, {"retention", "90days"}}}; + + nlohmann::json golden = R"({ + "namespace": ["prod","analytics"], + "properties": {"owner":"team-analytics","retention":"90days"} + })"_json; + + TestJsonConversion(r, golden); + TestJsonRoundTrip(r); +} + +TEST(JsonRestNewTest, GetNamespaceResponseMinimal) { + GetNamespaceResponse r{.namespace_ = Namespace{.levels = {"db"}}, .properties = {}}; + + nlohmann::json golden = R"({"namespace":["db"]})"_json; + + TestJsonConversion(r, golden); +} + +TEST(JsonRestNewTest, UpdateNamespacePropertiesResponseFull) { + UpdateNamespacePropertiesResponse full{ + .updated = {"u1", "u2"}, .removed = {"r1"}, .missing = {"m1"}}; + + nlohmann::json g_full = R"({ + "updated": ["u1","u2"], + "removed": ["r1"], + "missing": ["m1"] + })"_json; + + TestJsonConversion(full, g_full); + TestJsonRoundTrip(full); +} + +TEST(JsonRestNewTest, UpdateNamespacePropertiesResponseMinimal) { + UpdateNamespacePropertiesResponse minimal{ + .updated = {"u"}, .removed = {}, .missing = {}}; + + nlohmann::json g_min = R"({ + "updated": ["u"], + "removed": [] + })"_json; + + TestJsonConversion(minimal, g_min); +} + +TEST(JsonRestNewTest, UpdateNamespacePropertiesResponseNoMissing) { + UpdateNamespacePropertiesResponse resp{ + .updated = {"u1"}, .removed = {"r1"}, .missing = {}}; + + nlohmann::json golden = R"({ + "updated": ["u1"], + "removed": ["r1"] + })"_json; + + TestJsonConversion(resp, golden); +} + +TEST(JsonRestNewTest, ListTablesResponseWithPageToken) { + ListTablesResponse r{.next_page_token = "token456", + .identifiers = {CreateTableIdentifier({"db1"}, "t1"), + CreateTableIdentifier({"db2", "s"}, "t2")}}; + + nlohmann::json golden = R"({ + "next-page-token": "token456", + "identifiers": [ + {"namespace":["db1"], "name":"t1"}, + {"namespace":["db2","s"], "name":"t2"} + ] + })"_json; + + TestJsonConversion(r, golden); + TestJsonRoundTrip(r); +} + +TEST(JsonRestNewTest, ListTablesResponseWithoutPageToken) { + ListTablesResponse r{.next_page_token = "", + .identifiers = {CreateTableIdentifier({"db1"}, "t1")}}; + + nlohmann::json golden = R"({ + "identifiers": [ + {"namespace":["db1"], "name":"t1"} + ] + })"_json; + + TestJsonConversion(r, golden); +} + +TEST(JsonRestNewTest, ListTablesResponseEmpty) { + ListTablesResponse r{.next_page_token = "", .identifiers = {}}; + + nlohmann::json golden = R"({ + "identifiers": [] + })"_json; + + TestJsonConversion(r, golden); +} + +TEST(JsonRestNewTest, ListTablesResponseIgnoreUnknown) { + nlohmann::json minimal = R"({"identifiers":[{"namespace":["db"],"name":"t"}]})"_json; + TestUnknownFieldIgnored(minimal); +} + +TEST(JsonRestNewBoundaryTest, EmptyCollections) { + CreateNamespaceRequest request{ + .namespace_ = Namespace{.levels = {"ns"}}, + .properties = std::unordered_map{}}; + + auto json = ToJson(request); + EXPECT_FALSE(json.contains("properties")); + + auto parsed = CreateNamespaceRequestFromJson(json); + ASSERT_TRUE(parsed.has_value()); + EXPECT_TRUE(parsed->properties.empty()); +} + +TEST(JsonRestNewBoundaryTest, InvalidJsonStructure) { + nlohmann::json invalid = nlohmann::json::array(); + + auto result = ListNamespacesResponseFromJson(invalid); + EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError)); + + invalid = R"({ + "namespaces": "should be array" + })"_json; + + result = ListNamespacesResponseFromJson(invalid); + EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError)); +} + +TEST(JsonRestNewBoundaryTest, DeepNestedNamespace) { + std::vector deep_namespace; + for (int i = 0; i < 100; ++i) { + deep_namespace.push_back("level" + std::to_string(i)); + } + + CreateNamespaceRequest req{.namespace_ = Namespace{.levels = deep_namespace}, + .properties = {}}; + + TestJsonRoundTrip(req); +} + +} // namespace iceberg::rest From f249cc5eb4ac3c8ad1a705f68cbd5d2ea50b1026 Mon Sep 17 00:00:00 2001 From: Li Feiyang Date: Mon, 27 Oct 2025 18:19:43 +0800 Subject: [PATCH 2/4] fix review(test todo --- src/iceberg/catalog/rest/json_internal.cc | 186 ++++++-------------- src/iceberg/catalog/rest/json_internal.h | 166 ++++++----------- src/iceberg/catalog/rest/meson.build | 7 +- src/iceberg/catalog/rest/types.h | 4 +- src/iceberg/json_internal.cc | 30 ++++ src/iceberg/json_internal.h | 26 +++ src/iceberg/test/meson.build | 5 +- src/iceberg/test/rest_json_internal_test.cc | 4 +- src/iceberg/util/json_util_internal.h | 9 + 9 files changed, 187 insertions(+), 250 deletions(-) diff --git a/src/iceberg/catalog/rest/json_internal.cc b/src/iceberg/catalog/rest/json_internal.cc index 49b00e419..229001493 100644 --- a/src/iceberg/catalog/rest/json_internal.cc +++ b/src/iceberg/catalog/rest/json_internal.cc @@ -19,6 +19,7 @@ #include "iceberg/catalog/rest/json_internal.h" +#include #include #include #include @@ -26,6 +27,7 @@ #include +#include "iceberg/catalog/rest/types.h" #include "iceberg/json_internal.h" #include "iceberg/partition_spec.h" #include "iceberg/schema.h" @@ -62,27 +64,6 @@ constexpr std::string_view kMetadata = "metadata"; constexpr std::string_view kConfig = "config"; constexpr std::string_view kIdentifiers = "identifiers"; -using MapType = std::unordered_map; - -/// Helper function to convert TableIdentifier to JSON -nlohmann::json ToJson(const TableIdentifier& identifier) { - nlohmann::json json; - json[kNamespace] = identifier.ns.levels; - json[kName] = identifier.name; - return json; -} - -/// Helper function to parse TableIdentifier from JSON -Result TableIdentifierFromJson(const nlohmann::json& json) { - TableIdentifier identifier; - - ICEBERG_ASSIGN_OR_RAISE(identifier.ns.levels, - GetJsonValue>(json, kNamespace)); - ICEBERG_ASSIGN_OR_RAISE(identifier.name, GetJsonValue(json, kName)); - - return identifier; -} - } // namespace // CreateNamespaceRequest @@ -98,110 +79,90 @@ nlohmann::json ToJson(const CreateNamespaceRequest& request) { Result CreateNamespaceRequestFromJson( const nlohmann::json& json) { CreateNamespaceRequest request; - - ICEBERG_ASSIGN_OR_RAISE(auto levels, + ICEBERG_ASSIGN_OR_RAISE(request.namespace_.levels, GetJsonValue>(json, kNamespace)); - request.namespace_.levels = std::move(levels); - - ICEBERG_ASSIGN_OR_RAISE(request.properties, - GetJsonValueOrDefault(json, kProperties)); - + ICEBERG_ASSIGN_OR_RAISE( + request.properties, + (GetJsonValueOrDefault>(json, + kProperties))); return request; } // UpdateNamespacePropertiesRequest 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)); - + ICEBERG_ASSIGN_OR_RAISE( + request.updates, + (GetJsonValueOrDefault>(json, + kUpdates))); return request; } // CreateTableRequest nlohmann::json ToJson(const CreateTableRequest& request) { nlohmann::json json; - json[kName] = request.name; - if (!request.location.empty()) { json[kLocation] = request.location; } - - json[kSchema] = iceberg::ToJson(*request.schema); - + json[kSchema] = ToJson(*request.schema); if (request.partition_spec) { - json[kPartitionSpec] = iceberg::ToJson(*request.partition_spec); + json[kPartitionSpec] = ToJson(*request.partition_spec); } - if (request.write_order) { - json[kWriteOrder] = iceberg::ToJson(*request.write_order); + json[kWriteOrder] = ToJson(*request.write_order); } - SetOptionalField(json, kStageCreate, request.stage_create); - if (!request.properties.empty()) { json[kProperties] = request.properties; } - return json; } Result CreateTableRequestFromJson(const nlohmann::json& json) { CreateTableRequest request; - ICEBERG_ASSIGN_OR_RAISE(request.name, GetJsonValue(json, kName)); - - ICEBERG_ASSIGN_OR_RAISE(auto location, - GetJsonValueOptional(json, kLocation)); - request.location = location.value_or(""); - + ICEBERG_ASSIGN_OR_RAISE(request.location, + GetJsonValueOrDefault(json, kLocation, "")); ICEBERG_ASSIGN_OR_RAISE(auto schema_json, GetJsonValue(json, kSchema)); - ICEBERG_ASSIGN_OR_RAISE(auto schema_ptr, iceberg::SchemaFromJson(schema_json)); - request.schema = std::move(schema_ptr); - + ICEBERG_ASSIGN_OR_RAISE(request.schema, SchemaFromJson(schema_json)); if (json.contains(kPartitionSpec)) { ICEBERG_ASSIGN_OR_RAISE(auto partition_spec_json, GetJsonValue(json, kPartitionSpec)); - ICEBERG_ASSIGN_OR_RAISE( - request.partition_spec, - iceberg::PartitionSpecFromJson(request.schema, partition_spec_json)); + ICEBERG_ASSIGN_OR_RAISE(request.partition_spec, + PartitionSpecFromJson(request.schema, partition_spec_json)); } else { request.partition_spec = nullptr; } - if (json.contains(kWriteOrder)) { ICEBERG_ASSIGN_OR_RAISE(auto write_order_json, GetJsonValue(json, kWriteOrder)); - ICEBERG_ASSIGN_OR_RAISE(request.write_order, - iceberg::SortOrderFromJson(write_order_json)); + ICEBERG_ASSIGN_OR_RAISE(request.write_order, SortOrderFromJson(write_order_json)); } else { request.write_order = nullptr; } - ICEBERG_ASSIGN_OR_RAISE(request.stage_create, GetJsonValueOptional(json, kStageCreate)); - - ICEBERG_ASSIGN_OR_RAISE(request.properties, - GetJsonValueOrDefault(json, kProperties)); - + ICEBERG_ASSIGN_OR_RAISE( + request.properties, + (GetJsonValueOrDefault>(json, + kProperties))); return request; } @@ -218,16 +179,13 @@ nlohmann::json ToJson(const RegisterTableRequest& request) { 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)); - // Default to false if not present ICEBERG_ASSIGN_OR_RAISE(auto overwrite_opt, GetJsonValueOptional(json, kOverwrite)); request.overwrite = overwrite_opt.value_or(false); - return request; } @@ -241,85 +199,65 @@ nlohmann::json ToJson(const RenameTableRequest& request) { 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.has_value() && !result.metadata_location->empty()) { - json[kMetadataLocation] = result.metadata_location.value(); + if (!result.metadata_location.empty()) { + json[kMetadataLocation] = result.metadata_location; } - - json[kMetadata] = iceberg::ToJson(*result.metadata); - + 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(auto metadata_location, - GetJsonValueOptional(json, kMetadataLocation)); - result.metadata_location = metadata_location; - + ICEBERG_ASSIGN_OR_RAISE(result.metadata_location, GetJsonValueOrDefault( + json, kMetadataLocation, "")); ICEBERG_ASSIGN_OR_RAISE(auto metadata_json, GetJsonValue(json, kMetadata)); - ICEBERG_ASSIGN_OR_RAISE(auto metadata_ptr, - iceberg::TableMetadataFromJson(metadata_json)); - result.metadata = std::move(metadata_ptr); - - ICEBERG_ASSIGN_OR_RAISE(result.config, GetJsonValueOrDefault(json, kConfig)); - + ICEBERG_ASSIGN_OR_RAISE(result.metadata, TableMetadataFromJson(metadata_json)); + ICEBERG_ASSIGN_OR_RAISE( + result.config, (GetJsonValueOrDefault>( + json, kConfig))); return result; } // ListNamespacesResponse 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(ns.levels); + namespaces.push_back(ToJson(ns)); } json[kNamespaces] = std::move(namespaces); - return json; } Result ListNamespacesResponseFromJson( const nlohmann::json& json) { ListNamespacesResponse response; - - ICEBERG_ASSIGN_OR_RAISE(auto next_page_token, - GetJsonValueOptional(json, kNextPageToken)); - response.next_page_token = next_page_token.value_or(""); - - ICEBERG_ASSIGN_OR_RAISE( - auto namespace_levels, - GetJsonValue>>(json, kNamespaces)); - response.namespaces.reserve(namespace_levels.size()); - for (auto& levels : namespace_levels) { - response.namespaces.push_back(Namespace{.levels = std::move(levels)}); + 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; } @@ -336,14 +274,12 @@ nlohmann::json ToJson(const CreateNamespaceResponse& response) { Result CreateNamespaceResponseFromJson( const nlohmann::json& json) { CreateNamespaceResponse response; - - ICEBERG_ASSIGN_OR_RAISE(auto levels, + ICEBERG_ASSIGN_OR_RAISE(response.namespace_.levels, GetJsonValue>(json, kNamespace)); - response.namespace_.levels = std::move(levels); - - ICEBERG_ASSIGN_OR_RAISE(response.properties, - GetJsonValueOrDefault(json, kProperties)); - + ICEBERG_ASSIGN_OR_RAISE( + response.properties, + (GetJsonValueOrDefault>(json, + kProperties))); return response; } @@ -359,14 +295,12 @@ nlohmann::json ToJson(const GetNamespaceResponse& response) { Result GetNamespaceResponseFromJson(const nlohmann::json& json) { GetNamespaceResponse response; - - ICEBERG_ASSIGN_OR_RAISE(auto levels, + ICEBERG_ASSIGN_OR_RAISE(response.namespace_.levels, GetJsonValue>(json, kNamespace)); - response.namespace_.levels = std::move(levels); - - ICEBERG_ASSIGN_OR_RAISE(response.properties, - GetJsonValueOrDefault(json, kProperties)); - + ICEBERG_ASSIGN_OR_RAISE( + response.properties, + (GetJsonValueOrDefault>(json, + kProperties))); return response; } @@ -384,49 +318,39 @@ nlohmann::json ToJson(const UpdateNamespacePropertiesResponse& response) { 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; } // ListTablesResponse 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(auto next_page_token, - GetJsonValueOptional(json, kNextPageToken)); - response.next_page_token = next_page_token.value_or(""); - + 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; } diff --git a/src/iceberg/catalog/rest/json_internal.h b/src/iceberg/catalog/rest/json_internal.h index 08aa6cd23..3286e1c1d 100644 --- a/src/iceberg/catalog/rest/json_internal.h +++ b/src/iceberg/catalog/rest/json_internal.h @@ -26,141 +26,83 @@ namespace iceberg::rest { -/// \brief Serializes a `CreateNamespaceRequest` object to JSON. -/// -/// \param request The `CreateNamespaceRequest` object to be serialized. -/// \return A JSON object representing the `CreateNamespaceRequest`. -nlohmann::json ToJson(const CreateNamespaceRequest& request); +/// \brief Serializes a `ListNamespacesResponse` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const ListNamespacesResponse& response); -/// \brief Deserializes a JSON object into a `CreateNamespaceRequest` object. -/// -/// \param json The JSON object representing a `CreateNamespaceRequest`. -/// \return A `CreateNamespaceRequest` object or an error if the conversion fails. -Result CreateNamespaceRequestFromJson(const nlohmann::json& json); +/// \brief Deserializes a JSON object into a `ListNamespacesResponse` object. +ICEBERG_REST_EXPORT Result ListNamespacesResponseFromJson( + const nlohmann::json& json); -/// \brief Serializes an `UpdateNamespacePropertiesRequest` object to JSON. -/// -/// \param request The `UpdateNamespacePropertiesRequest` object to be serialized. -/// \return A JSON object representing the `UpdateNamespacePropertiesRequest`. -nlohmann::json ToJson(const UpdateNamespacePropertiesRequest& request); +/// \brief Serializes a `CreateNamespaceRequest` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const CreateNamespaceRequest& request); -/// \brief Deserializes a JSON object into an `UpdateNamespacePropertiesRequest` object. -/// -/// \param json The JSON object representing an `UpdateNamespacePropertiesRequest`. -/// \return An `UpdateNamespacePropertiesRequest` object or an error if the conversion -/// fails. -Result UpdateNamespacePropertiesRequestFromJson( +/// \brief Deserializes a JSON object into a `CreateNamespaceRequest` object. +ICEBERG_REST_EXPORT Result CreateNamespaceRequestFromJson( const nlohmann::json& json); -/// \brief Serializes a `CreateTableRequest` object to JSON. -/// -/// \param request The `CreateTableRequest` object to be serialized. -/// \return A JSON object representing the `CreateTableRequest`. -nlohmann::json ToJson(const CreateTableRequest& request); +/// \brief Serializes a `CreateNamespaceResponse` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const CreateNamespaceResponse& response); -/// \brief Deserializes a JSON object into a `CreateTableRequest` object. -/// -/// \param json The JSON object representing a `CreateTableRequest`. -/// \return A `CreateTableRequest` object or an error if the conversion fails. -Result CreateTableRequestFromJson(const nlohmann::json& json); +/// \brief Deserializes a JSON object into a `CreateNamespaceResponse` object. +ICEBERG_REST_EXPORT Result CreateNamespaceResponseFromJson( + const nlohmann::json& json); -/// \brief Serializes a `RegisterTableRequest` object to JSON. -/// -/// \param request The `RegisterTableRequest` object to be serialized. -/// \return A JSON object representing the `RegisterTableRequest`. -nlohmann::json ToJson(const RegisterTableRequest& request); +/// \brief Serializes a `GetNamespaceResponse` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const GetNamespaceResponse& response); -/// \brief Deserializes a JSON object into a `RegisterTableRequest` object. -/// -/// \param json The JSON object representing a `RegisterTableRequest`. -/// \return A `RegisterTableRequest` object or an error if the conversion fails. -Result RegisterTableRequestFromJson(const nlohmann::json& json); +/// \brief Deserializes a JSON object into a `GetNamespaceResponse` object. +ICEBERG_REST_EXPORT Result GetNamespaceResponseFromJson( + const nlohmann::json& json); -/// \brief Serializes a `RenameTableRequest` object to JSON. -/// -/// \param request The `RenameTableRequest` object to be serialized. -/// \return A JSON object representing the `RenameTableRequest`. -nlohmann::json ToJson(const RenameTableRequest& request); +/// \brief Serializes an `UpdateNamespacePropertiesRequest` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson( + const UpdateNamespacePropertiesRequest& request); -/// \brief Deserializes a JSON object into a `RenameTableRequest` object. -/// -/// \param json The JSON object representing a `RenameTableRequest`. -/// \return A `RenameTableRequest` object or an error if the conversion fails. -Result RenameTableRequestFromJson(const nlohmann::json& json); +/// \brief Deserializes a JSON object into an `UpdateNamespacePropertiesRequest` object. +ICEBERG_REST_EXPORT Result +UpdateNamespacePropertiesRequestFromJson(const nlohmann::json& json); -/// \brief Serializes a `LoadTableResult` object to JSON. -/// -/// \param result The `LoadTableResult` object to be serialized. -/// \return A JSON object representing the `LoadTableResult`. -nlohmann::json ToJson(const LoadTableResult& result); +/// \brief Serializes an `UpdateNamespacePropertiesResponse` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson( + const UpdateNamespacePropertiesResponse& response); -/// \brief Deserializes a JSON object into a `LoadTableResult` object. -/// -/// \param json The JSON object representing a `LoadTableResult`. -/// \return A `LoadTableResult` object or an error if the conversion fails. -Result LoadTableResultFromJson(const nlohmann::json& json); +/// \brief Deserializes a JSON object into an `UpdateNamespacePropertiesResponse` object. +ICEBERG_REST_EXPORT Result +UpdateNamespacePropertiesResponseFromJson(const nlohmann::json& json); -/// \brief Serializes a `ListNamespacesResponse` object to JSON. -/// -/// \param response The `ListNamespacesResponse` object to be serialized. -/// \return A JSON object representing the `ListNamespacesResponse`. -nlohmann::json ToJson(const ListNamespacesResponse& response); +/// \brief Serializes a `ListTablesResponse` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const ListTablesResponse& response); -/// \brief Deserializes a JSON object into a `ListNamespacesResponse` object. -/// -/// \param json The JSON object representing a `ListNamespacesResponse`. -/// \return A `ListNamespacesResponse` object or an error if the conversion fails. -Result ListNamespacesResponseFromJson(const nlohmann::json& json); +/// \brief Deserializes a JSON object into a `ListTablesResponse` object. +ICEBERG_REST_EXPORT Result ListTablesResponseFromJson( + const nlohmann::json& json); -/// \brief Serializes a `CreateNamespaceResponse` object to JSON. -/// -/// \param response The `CreateNamespaceResponse` object to be serialized. -/// \return A JSON object representing the `CreateNamespaceResponse`. -nlohmann::json ToJson(const CreateNamespaceResponse& response); +/// \brief Serializes a `CreateTableRequest` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const CreateTableRequest& request); -/// \brief Deserializes a JSON object into a `CreateNamespaceResponse` object. -/// -/// \param json The JSON object representing a `CreateNamespaceResponse`. -/// \return A `CreateNamespaceResponse` object or an error if the conversion fails. -Result CreateNamespaceResponseFromJson( +/// \brief Deserializes a JSON object into a `CreateTableRequest` object. +ICEBERG_REST_EXPORT Result CreateTableRequestFromJson( const nlohmann::json& json); -/// \brief Serializes a `GetNamespaceResponse` object to JSON. -/// -/// \param response The `GetNamespaceResponse` object to be serialized. -/// \return A JSON object representing the `GetNamespaceResponse`. -nlohmann::json ToJson(const GetNamespaceResponse& response); +/// \brief Serializes a `LoadTableResult` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const LoadTableResult& result); -/// \brief Deserializes a JSON object into a `GetNamespaceResponse` object. -/// -/// \param json The JSON object representing a `GetNamespaceResponse`. -/// \return A `GetNamespaceResponse` object or an error if the conversion fails. -Result GetNamespaceResponseFromJson(const nlohmann::json& json); +/// \brief Deserializes a JSON object into a `LoadTableResult` object. +ICEBERG_REST_EXPORT Result LoadTableResultFromJson( + const nlohmann::json& json); -/// \brief Serializes an `UpdateNamespacePropertiesResponse` object to JSON. -/// -/// \param response The `UpdateNamespacePropertiesResponse` object to be serialized. -/// \return A JSON object representing the `UpdateNamespacePropertiesResponse`. -nlohmann::json ToJson(const UpdateNamespacePropertiesResponse& response); +/// \brief Serializes a `RegisterTableRequest` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const RegisterTableRequest& request); -/// \brief Deserializes a JSON object into an `UpdateNamespacePropertiesResponse` object. -/// -/// \param json The JSON object representing an `UpdateNamespacePropertiesResponse`. -/// \return An `UpdateNamespacePropertiesResponse` object or an error if the conversion -/// fails. -Result UpdateNamespacePropertiesResponseFromJson( +/// \brief Deserializes a JSON object into a `RegisterTableRequest` object. +ICEBERG_REST_EXPORT Result RegisterTableRequestFromJson( const nlohmann::json& json); -/// \brief Serializes a `ListTablesResponse` object to JSON. -/// -/// \param response The `ListTablesResponse` object to be serialized. -/// \return A JSON object representing the `ListTablesResponse`. -nlohmann::json ToJson(const ListTablesResponse& response); +/// \brief Serializes a `RenameTableRequest` object to JSON. +ICEBERG_REST_EXPORT nlohmann::json ToJson(const RenameTableRequest& request); -/// \brief Deserializes a JSON object into a `ListTablesResponse` object. -/// -/// \param json The JSON object representing a `ListTablesResponse`. -/// \return A `ListTablesResponse` object or an error if the conversion fails. -Result ListTablesResponseFromJson(const nlohmann::json& json); +/// \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..93247d05d 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 = ( @@ -46,4 +46,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'], + subdir: 'iceberg/catalog/rest', +) diff --git a/src/iceberg/catalog/rest/types.h b/src/iceberg/catalog/rest/types.h index 4c50ab268..62b9f7116 100644 --- a/src/iceberg/catalog/rest/types.h +++ b/src/iceberg/catalog/rest/types.h @@ -75,8 +75,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/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 index 510a8d49e..3e7b7ac27 100644 --- a/src/iceberg/test/rest_json_internal_test.cc +++ b/src/iceberg/test/rest_json_internal_test.cc @@ -33,8 +33,8 @@ #include "iceberg/sort_order.h" #include "iceberg/table_identifier.h" #include "iceberg/table_metadata.h" +#include "iceberg/test/matchers.h" #include "iceberg/transform.h" -#include "matchers.h" namespace iceberg::rest { @@ -442,7 +442,7 @@ TEST(JsonRestNewTest, LoadTableResultFull) { TEST(JsonRestNewTest, LoadTableResultMinimal) { LoadTableResult r{ - .metadata_location = std::nullopt, .metadata = CreateTableMetadata(), .config = {}}; + .metadata_location = "", .metadata = CreateTableMetadata(), .config = {}}; TestJsonRoundTrip(r); 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 { From e19f740722b1eb56d537b4c5f8675b08570e2222 Mon Sep 17 00:00:00 2001 From: Li Feiyang Date: Mon, 3 Nov 2025 17:14:21 +0800 Subject: [PATCH 3/4] fix review --- src/iceberg/catalog/rest/json_internal.cc | 94 +- src/iceberg/catalog/rest/json_internal.h | 7 - src/iceberg/catalog/rest/meson.build | 5 +- src/iceberg/catalog/rest/types.h | 11 - src/iceberg/test/rest_json_internal_test.cc | 1461 +++++++++++-------- 5 files changed, 870 insertions(+), 708 deletions(-) diff --git a/src/iceberg/catalog/rest/json_internal.cc b/src/iceberg/catalog/rest/json_internal.cc index 229001493..55f1c38b7 100644 --- a/src/iceberg/catalog/rest/json_internal.cc +++ b/src/iceberg/catalog/rest/json_internal.cc @@ -19,7 +19,6 @@ #include "iceberg/catalog/rest/json_internal.h" -#include #include #include #include @@ -29,10 +28,7 @@ #include "iceberg/catalog/rest/types.h" #include "iceberg/json_internal.h" -#include "iceberg/partition_spec.h" -#include "iceberg/schema.h" -#include "iceberg/sort_order.h" -#include "iceberg/table_metadata.h" +#include "iceberg/table_identifier.h" #include "iceberg/util/json_util_internal.h" #include "iceberg/util/macros.h" @@ -66,7 +62,6 @@ constexpr std::string_view kIdentifiers = "identifiers"; } // namespace -// CreateNamespaceRequest nlohmann::json ToJson(const CreateNamespaceRequest& request) { nlohmann::json json; json[kNamespace] = request.namespace_.levels; @@ -83,12 +78,10 @@ Result CreateNamespaceRequestFromJson( GetJsonValue>(json, kNamespace)); ICEBERG_ASSIGN_OR_RAISE( request.properties, - (GetJsonValueOrDefault>(json, - kProperties))); + GetJsonValueOrDefault(json, kProperties)); return request; } -// UpdateNamespacePropertiesRequest nlohmann::json ToJson(const UpdateNamespacePropertiesRequest& request) { // Initialize as an empty object so that when all optional fields are absent we return // {} instead of null @@ -108,65 +101,10 @@ Result UpdateNamespacePropertiesRequestFromJso ICEBERG_ASSIGN_OR_RAISE( request.removals, GetJsonValueOrDefault>(json, kRemovals)); ICEBERG_ASSIGN_OR_RAISE( - request.updates, - (GetJsonValueOrDefault>(json, - kUpdates))); + request.updates, GetJsonValueOrDefault(json, kUpdates)); return request; } -// CreateTableRequest -nlohmann::json ToJson(const CreateTableRequest& request) { - nlohmann::json json; - json[kName] = request.name; - if (!request.location.empty()) { - json[kLocation] = request.location; - } - json[kSchema] = ToJson(*request.schema); - if (request.partition_spec) { - json[kPartitionSpec] = ToJson(*request.partition_spec); - } - if (request.write_order) { - json[kWriteOrder] = ToJson(*request.write_order); - } - SetOptionalField(json, kStageCreate, request.stage_create); - if (!request.properties.empty()) { - json[kProperties] = request.properties; - } - return json; -} - -Result CreateTableRequestFromJson(const nlohmann::json& json) { - CreateTableRequest request; - ICEBERG_ASSIGN_OR_RAISE(request.name, GetJsonValue(json, kName)); - ICEBERG_ASSIGN_OR_RAISE(request.location, - GetJsonValueOrDefault(json, kLocation, "")); - ICEBERG_ASSIGN_OR_RAISE(auto schema_json, GetJsonValue(json, kSchema)); - ICEBERG_ASSIGN_OR_RAISE(request.schema, SchemaFromJson(schema_json)); - if (json.contains(kPartitionSpec)) { - ICEBERG_ASSIGN_OR_RAISE(auto partition_spec_json, - GetJsonValue(json, kPartitionSpec)); - ICEBERG_ASSIGN_OR_RAISE(request.partition_spec, - PartitionSpecFromJson(request.schema, partition_spec_json)); - } else { - request.partition_spec = nullptr; - } - if (json.contains(kWriteOrder)) { - ICEBERG_ASSIGN_OR_RAISE(auto write_order_json, - GetJsonValue(json, kWriteOrder)); - ICEBERG_ASSIGN_OR_RAISE(request.write_order, SortOrderFromJson(write_order_json)); - } else { - request.write_order = nullptr; - } - ICEBERG_ASSIGN_OR_RAISE(request.stage_create, - GetJsonValueOptional(json, kStageCreate)); - ICEBERG_ASSIGN_OR_RAISE( - request.properties, - (GetJsonValueOrDefault>(json, - kProperties))); - return request; -} - -// RegisterTableRequest nlohmann::json ToJson(const RegisterTableRequest& request) { nlohmann::json json; json[kName] = request.name; @@ -182,14 +120,11 @@ Result RegisterTableRequestFromJson(const nlohmann::json& ICEBERG_ASSIGN_OR_RAISE(request.name, GetJsonValue(json, kName)); ICEBERG_ASSIGN_OR_RAISE(request.metadata_location, GetJsonValue(json, kMetadataLocation)); - // Default to false if not present - ICEBERG_ASSIGN_OR_RAISE(auto overwrite_opt, - GetJsonValueOptional(json, kOverwrite)); - request.overwrite = overwrite_opt.value_or(false); + ICEBERG_ASSIGN_OR_RAISE(request.overwrite, + GetJsonValueOrDefault(json, kOverwrite, false)); return request; } -// RenameTableRequest nlohmann::json ToJson(const RenameTableRequest& request) { nlohmann::json json; json[kSource] = ToJson(request.source); @@ -222,8 +157,8 @@ nlohmann::json ToJson(const LoadTableResult& result) { Result LoadTableResultFromJson(const nlohmann::json& json) { LoadTableResult result; - ICEBERG_ASSIGN_OR_RAISE(result.metadata_location, GetJsonValueOrDefault( - json, kMetadataLocation, "")); + 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)); @@ -233,7 +168,6 @@ Result LoadTableResultFromJson(const nlohmann::json& json) { return result; } -// ListNamespacesResponse nlohmann::json ToJson(const ListNamespacesResponse& response) { nlohmann::json json; if (!response.next_page_token.empty()) { @@ -251,7 +185,7 @@ Result ListNamespacesResponseFromJson( const nlohmann::json& json) { ListNamespacesResponse response; ICEBERG_ASSIGN_OR_RAISE(response.next_page_token, - GetJsonValueOrDefault(json, kNextPageToken, "")); + GetJsonValueOrDefault(json, kNextPageToken)); ICEBERG_ASSIGN_OR_RAISE(auto namespaces_json, GetJsonValue(json, kNamespaces)); for (const auto& ns_json : namespaces_json) { @@ -261,7 +195,6 @@ Result ListNamespacesResponseFromJson( return response; } -// CreateNamespaceResponse nlohmann::json ToJson(const CreateNamespaceResponse& response) { nlohmann::json json; json[kNamespace] = response.namespace_.levels; @@ -278,12 +211,10 @@ Result CreateNamespaceResponseFromJson( GetJsonValue>(json, kNamespace)); ICEBERG_ASSIGN_OR_RAISE( response.properties, - (GetJsonValueOrDefault>(json, - kProperties))); + GetJsonValueOrDefault(json, kProperties)); return response; } -// GetNamespaceResponse nlohmann::json ToJson(const GetNamespaceResponse& response) { nlohmann::json json; json[kNamespace] = response.namespace_.levels; @@ -299,12 +230,10 @@ Result GetNamespaceResponseFromJson(const nlohmann::json& GetJsonValue>(json, kNamespace)); ICEBERG_ASSIGN_OR_RAISE( response.properties, - (GetJsonValueOrDefault>(json, - kProperties))); + GetJsonValueOrDefault(json, kProperties)); return response; } -// UpdateNamespacePropertiesResponse nlohmann::json ToJson(const UpdateNamespacePropertiesResponse& response) { nlohmann::json json; json[kUpdated] = response.updated; @@ -327,7 +256,6 @@ Result UpdateNamespacePropertiesResponseFromJ return response; } -// ListTablesResponse nlohmann::json ToJson(const ListTablesResponse& response) { nlohmann::json json; if (!response.next_page_token.empty()) { @@ -344,7 +272,7 @@ nlohmann::json ToJson(const ListTablesResponse& response) { Result ListTablesResponseFromJson(const nlohmann::json& json) { ListTablesResponse response; ICEBERG_ASSIGN_OR_RAISE(response.next_page_token, - GetJsonValueOrDefault(json, kNextPageToken, "")); + GetJsonValueOrDefault(json, kNextPageToken)); ICEBERG_ASSIGN_OR_RAISE(auto identifiers_json, GetJsonValue(json, kIdentifiers)); for (const auto& id_json : identifiers_json) { diff --git a/src/iceberg/catalog/rest/json_internal.h b/src/iceberg/catalog/rest/json_internal.h index 3286e1c1d..11b567a01 100644 --- a/src/iceberg/catalog/rest/json_internal.h +++ b/src/iceberg/catalog/rest/json_internal.h @@ -77,13 +77,6 @@ ICEBERG_REST_EXPORT nlohmann::json ToJson(const ListTablesResponse& response); ICEBERG_REST_EXPORT Result ListTablesResponseFromJson( const nlohmann::json& json); -/// \brief Serializes a `CreateTableRequest` object to JSON. -ICEBERG_REST_EXPORT nlohmann::json ToJson(const CreateTableRequest& request); - -/// \brief Deserializes a JSON object into a `CreateTableRequest` object. -ICEBERG_REST_EXPORT Result CreateTableRequestFromJson( - const nlohmann::json& json); - /// \brief Serializes a `LoadTableResult` object to JSON. ICEBERG_REST_EXPORT nlohmann::json ToJson(const LoadTableResult& result); diff --git a/src/iceberg/catalog/rest/meson.build b/src/iceberg/catalog/rest/meson.build index 93247d05d..5f1f635ab 100644 --- a/src/iceberg/catalog/rest/meson.build +++ b/src/iceberg/catalog/rest/meson.build @@ -46,7 +46,4 @@ 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', 'json_internal.h'], - subdir: 'iceberg/catalog/rest', -) +install_headers(['rest_catalog.h', 'types.h'], subdir: 'iceberg/catalog/rest') diff --git a/src/iceberg/catalog/rest/types.h b/src/iceberg/catalog/rest/types.h index 62b9f7116..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 diff --git a/src/iceberg/test/rest_json_internal_test.cc b/src/iceberg/test/rest_json_internal_test.cc index 3e7b7ac27..c042f7f8d 100644 --- a/src/iceberg/test/rest_json_internal_test.cc +++ b/src/iceberg/test/rest_json_internal_test.cc @@ -17,7 +17,7 @@ * under the License. */ -#include +#include #include #include #include @@ -27,638 +27,893 @@ #include #include "iceberg/catalog/rest/json_internal.h" -#include "iceberg/partition_spec.h" -#include "iceberg/schema.h" -#include "iceberg/snapshot.h" -#include "iceberg/sort_order.h" +#include "iceberg/catalog/rest/types.h" +#include "iceberg/result.h" #include "iceberg/table_identifier.h" -#include "iceberg/table_metadata.h" #include "iceberg/test/matchers.h" -#include "iceberg/transform.h" namespace iceberg::rest { -namespace { - -// Helper templates for type-specific deserialization -template -Result FromJsonHelper(const nlohmann::json& json); - -template <> -Result FromJsonHelper( - const nlohmann::json& j) { - return CreateNamespaceRequestFromJson(j); -} - -template <> -Result FromJsonHelper( - const nlohmann::json& j) { - return UpdateNamespacePropertiesRequestFromJson(j); -} - -template <> -Result FromJsonHelper(const nlohmann::json& j) { - return CreateTableRequestFromJson(j); -} - -template <> -Result FromJsonHelper( - const nlohmann::json& j) { - return RegisterTableRequestFromJson(j); -} - -template <> -Result FromJsonHelper(const nlohmann::json& j) { - return RenameTableRequestFromJson(j); -} - -template <> -Result FromJsonHelper(const nlohmann::json& j) { - return LoadTableResultFromJson(j); -} - -template <> -Result FromJsonHelper( - const nlohmann::json& j) { - return ListNamespacesResponseFromJson(j); -} - -template <> -Result FromJsonHelper( - const nlohmann::json& j) { - return CreateNamespaceResponseFromJson(j); -} - -template <> -Result FromJsonHelper( - const nlohmann::json& j) { - return GetNamespaceResponseFromJson(j); -} - -template <> -Result -FromJsonHelper(const nlohmann::json& j) { - return UpdateNamespacePropertiesResponseFromJson(j); -} - -template <> -Result FromJsonHelper(const nlohmann::json& j) { - return ListTablesResponseFromJson(j); -} - -// Test helper functions -template -void TestJsonRoundTrip(const T& obj) { - auto j = ToJson(obj); - auto parsed = FromJsonHelper(j); - ASSERT_TRUE(parsed.has_value()) << parsed.error().message; - auto j2 = ToJson(parsed.value()); - EXPECT_EQ(j, j2) << "Round-trip JSON mismatch."; -} - -template -void TestJsonConversion(const T& obj, const nlohmann::json& expected_json) { - auto got = ToJson(obj); - EXPECT_EQ(expected_json, got) << "JSON conversion mismatch."; - - auto parsed = FromJsonHelper(expected_json); - ASSERT_TRUE(parsed.has_value()) << parsed.error().message; - auto back = ToJson(parsed.value()); - EXPECT_EQ(expected_json, back) << "JSON conversion mismatch."; -} - -template -void TestMissingRequiredField(const nlohmann::json& invalid_json, - const std::string& expected_field_name) { - auto result = FromJsonHelper(invalid_json); - EXPECT_FALSE(result.has_value()); - EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError)); - EXPECT_THAT(result, HasErrorMessage("Missing '" + expected_field_name + "'")); -} - -template -void TestUnknownFieldIgnored(const nlohmann::json& minimal_valid) { - auto j = minimal_valid; - j["__unknown__"] = {{"nested", 1}}; - auto parsed = FromJsonHelper(j); - ASSERT_TRUE(parsed.has_value()) << parsed.error().message; - auto out = ToJson(parsed.value()); - EXPECT_FALSE(out.contains("__unknown__")); -} - -std::shared_ptr CreateTableMetadata() { - std::vector schema_fields{ - SchemaField(1, "id", iceberg::int64(), false), - SchemaField(2, "name", iceberg::string(), true), - SchemaField(3, "ts", iceberg::timestamp(), false), - }; - auto schema = std::make_shared(std::move(schema_fields), /*schema_id=*/1); - - auto spec = std::make_shared( - schema, 1, - std::vector{PartitionField(3, 1000, "ts_day", Transform::Day())}); - - auto order = std::make_shared( - 1, std::vector{SortField(1, Transform::Identity(), - SortDirection::kAscending, NullOrder::kFirst)}); - - return std::make_shared(TableMetadata{ - .format_version = 2, - .table_uuid = "test-uuid-12345", - .location = "s3://bucket/warehouse/table", - .last_sequence_number = 42, - .last_updated_ms = TimePointMsFromUnixMs(1700000000000).value(), - .last_column_id = 3, - .schemas = {schema}, - .current_schema_id = 1, - .partition_specs = {spec}, - .default_spec_id = 1, - .last_partition_id = 1000, - .properties = {{"engine.version", "1.2.3"}, {"write.format.default", "parquet"}}, - .current_snapshot_id = 1234567890, - .snapshots = {std::make_shared( - Snapshot{.snapshot_id = 1234567890, - .sequence_number = 1, - .timestamp_ms = TimePointMsFromUnixMs(1699999999999).value(), - .manifest_list = "s3://bucket/metadata/snap-1234567890.avro", - .summary = {{"operation", "append"}, {"added-files", "10"}}})}, - .snapshot_log = {}, - .metadata_log = {}, - .sort_orders = {order}, - .default_sort_order_id = 1, - .refs = {}, - .statistics = {}, - .partition_statistics = {}, - .next_row_id = 100}); -} - -TableIdentifier CreateTableIdentifier(std::vector ns, std::string name) { - return TableIdentifier{.ns = Namespace{std::move(ns)}, .name = std::move(name)}; -} - -} // namespace - -TEST(JsonRestNewTest, CreateNamespaceRequestFull) { - CreateNamespaceRequest req{ - .namespace_ = Namespace{.levels = {"db1", "schema1"}}, - .properties = std::unordered_map{ - {"owner", "user1"}, {"location", "/warehouse/db1/schema1"}}}; - - nlohmann::json golden = R"({ - "namespace": ["db1","schema1"], - "properties": {"owner":"user1","location":"/warehouse/db1/schema1"} - })"_json; - - TestJsonConversion(req, golden); - TestJsonRoundTrip(req); -} - -TEST(JsonRestNewTest, CreateNamespaceRequestMinimal) { - CreateNamespaceRequest req{.namespace_ = Namespace{.levels = {"db_only"}}, - .properties = {}}; - - nlohmann::json golden = R"({ - "namespace": ["db_only"] - })"_json; - - TestJsonConversion(req, golden); -} - -TEST(JsonRestNewTest, CreateNamespaceRequestMissingNamespace) { - nlohmann::json invalid = R"({ - "properties": {"key": "value"} - })"_json; - - TestMissingRequiredField(invalid, "namespace"); -} - -TEST(JsonRestNewTest, CreateNamespaceRequestIgnoreUnknown) { - nlohmann::json minimal = R"({"namespace":["db"]})"_json; - TestUnknownFieldIgnored(minimal); -} - -TEST(JsonRestNewTest, UpdateNamespacePropertiesRequestBoth) { - UpdateNamespacePropertiesRequest req{ - .removals = std::vector{"k1", "k2"}, - .updates = std::unordered_map{{"a", "b"}}}; - - nlohmann::json golden = R"({ - "removals": ["k1","k2"], - "updates": {"a":"b"} - })"_json; - - TestJsonConversion(req, golden); - TestJsonRoundTrip(req); -} - -TEST(JsonRestNewTest, UpdateNamespacePropertiesRequestRemovalsOnly) { - UpdateNamespacePropertiesRequest req{.removals = std::vector{"k"}, - .updates = {}}; - - nlohmann::json golden = R"({ - "removals": ["k"] - })"_json; - - TestJsonConversion(req, golden); +bool operator==(const CreateNamespaceRequest& lhs, const CreateNamespaceRequest& rhs) { + return lhs.namespace_.levels == rhs.namespace_.levels && + lhs.properties == rhs.properties; } -TEST(JsonRestNewTest, UpdateNamespacePropertiesRequestUpdatesOnly) { - UpdateNamespacePropertiesRequest req{ - .removals = {}, - .updates = std::unordered_map{{"x", "y"}}}; - - nlohmann::json golden = R"({ - "updates": {"x":"y"} - })"_json; - - TestJsonConversion(req, golden); -} - -TEST(JsonRestNewTest, UpdateNamespacePropertiesRequestEmpty) { - UpdateNamespacePropertiesRequest req{.removals = {}, .updates = {}}; - - nlohmann::json golden = R"({})"_json; - - TestJsonConversion(req, golden); -} - -TEST(JsonRestNewTest, CreateTableRequestFull) { - auto schema = std::make_shared( - std::vector{SchemaField(1, "id", iceberg::int64(), false), - SchemaField(2, "ts", iceberg::timestamp(), false), - SchemaField(3, "data", iceberg::string(), true)}, - 0); - - auto partition_spec = - std::make_shared(schema, 1, - std::vector{PartitionField( - 2, 1000, "ts_month", Transform::Month())}); - - SortField sort_field(1, Transform::Identity(), SortDirection::kAscending, - NullOrder::kFirst); - auto write_order = std::make_shared(1, std::vector{sort_field}); - - CreateTableRequest request{ - .name = "test_table", - .location = "s3://bucket/warehouse/test_table", - .schema = schema, - .partition_spec = partition_spec, - .write_order = write_order, - .stage_create = true, - .properties = std::unordered_map{ - {"write.format.default", "parquet"}, {"commit.retry.num-retries", "10"}}}; - - TestJsonRoundTrip(request); +bool operator==(const UpdateNamespacePropertiesRequest& lhs, + const UpdateNamespacePropertiesRequest& rhs) { + return lhs.removals == rhs.removals && lhs.updates == rhs.updates; } -TEST(JsonRestNewTest, CreateTableRequestMinimal) { - auto schema = std::make_shared( - std::vector{SchemaField(1, "id", iceberg::int64(), false)}, 0); - - CreateTableRequest request{.name = "minimal_table", - .location = "", - .schema = schema, - .partition_spec = nullptr, - .write_order = nullptr, - .stage_create = std::nullopt, - .properties = {}}; - - TestJsonRoundTrip(request); - - auto json = ToJson(request); - EXPECT_EQ(json["name"], "minimal_table"); - EXPECT_FALSE(json.contains("location")); - EXPECT_TRUE(json.contains("schema")); - EXPECT_FALSE(json.contains("partition-spec")); - EXPECT_FALSE(json.contains("write-order")); - EXPECT_FALSE(json.contains("stage-create")); - EXPECT_FALSE(json.contains("properties")); +bool operator==(const RegisterTableRequest& lhs, const RegisterTableRequest& rhs) { + return lhs.name == rhs.name && lhs.metadata_location == rhs.metadata_location && + lhs.overwrite == rhs.overwrite; } -TEST(JsonRestNewTest, CreateTableRequestMissingRequiredFields) { - nlohmann::json invalid_json = R"({ - "location": "/tmp/test" - })"_json; - - TestMissingRequiredField(invalid_json, "name"); - - invalid_json = R"({ - "name": "test_table" - })"_json; - - TestMissingRequiredField(invalid_json, "schema"); -} - -TEST(JsonRestNewTest, RegisterTableRequestWithOverwriteTrue) { - RegisterTableRequest r1{ - .name = "t", .metadata_location = "s3://m/v1.json", .overwrite = true}; - - nlohmann::json g1 = R"({ - "name":"t", - "metadata-location":"s3://m/v1.json", - "overwrite": true - })"_json; - - TestJsonConversion(r1, g1); - TestJsonRoundTrip(r1); -} - -TEST(JsonRestNewTest, RegisterTableRequestWithOverwriteFalse) { - RegisterTableRequest r2{ - .name = "t2", .metadata_location = "/tmp/m.json", .overwrite = false}; - - nlohmann::json g2 = R"({ - "name":"t2", - "metadata-location":"/tmp/m.json" - })"_json; - - TestJsonConversion(r2, g2); +bool operator==(const CreateNamespaceResponse& lhs, const CreateNamespaceResponse& rhs) { + return lhs.namespace_.levels == rhs.namespace_.levels && + lhs.properties == rhs.properties; } -TEST(JsonRestNewTest, RegisterTableRequestMissingFields) { - TestMissingRequiredField( - R"({"metadata-location":"s3://m/v1.json"})"_json, "name"); - TestMissingRequiredField(R"({"name":"t"})"_json, - "metadata-location"); +bool operator==(const GetNamespaceResponse& lhs, const GetNamespaceResponse& rhs) { + return lhs.namespace_.levels == rhs.namespace_.levels && + lhs.properties == rhs.properties; } -TEST(JsonRestNewTest, RenameTableRequestBasic) { - RenameTableRequest req{.source = CreateTableIdentifier({"old"}, "t0"), - .destination = CreateTableIdentifier({"new", "s"}, "t1")}; - - nlohmann::json golden = R"({ - "source": {"namespace":["old"], "name":"t0"}, - "destination": {"namespace":["new","s"], "name":"t1"} - })"_json; - - TestJsonConversion(req, golden); - TestJsonRoundTrip(req); +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; } -TEST(JsonRestNewTest, RenameTableRequestDecodeWithUnknownField) { - nlohmann::json golden = R"({ - "source": {"namespace":["a"], "name":"b"}, - "destination": {"namespace":["x","y"], "name":"z"}, - "__extra__": true - })"_json; - - auto parsed = RenameTableRequestFromJson(golden); - ASSERT_TRUE(parsed.has_value()); - EXPECT_EQ(parsed->source.ns.levels, (std::vector{"a"})); - EXPECT_EQ(parsed->destination.ns.levels, (std::vector{"x", "y"})); +bool operator==(const UpdateNamespacePropertiesResponse& lhs, + const UpdateNamespacePropertiesResponse& rhs) { + return lhs.updated == rhs.updated && lhs.removed == rhs.removed && + lhs.missing == rhs.missing; } -TEST(JsonRestNewTest, RenameTableRequestInvalidNested) { - nlohmann::json invalid = R"({ - "source": { - "namespace": "should be array", - "name": "table" - }, - "destination": { - "namespace": ["db"], - "name": "new_table" +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; } - })"_json; - - auto result = RenameTableRequestFromJson(invalid); - EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError)); -} - -TEST(JsonRestNewTest, LoadTableResultFull) { - LoadTableResult r{.metadata_location = "s3://bucket/metadata/v1.json", - .metadata = CreateTableMetadata(), - .config = std::unordered_map{ - {"catalog-impl", "org.apache.iceberg.rest.RESTCatalog"}, - {"warehouse", "s3://bucket/warehouse"}}}; - - TestJsonRoundTrip(r); - - auto j = ToJson(r); - EXPECT_TRUE(j.contains("metadata-location")); - EXPECT_TRUE(j.contains("metadata")); - EXPECT_TRUE(j.contains("config")); - EXPECT_EQ(j["metadata"]["table-uuid"], "test-uuid-12345"); -} - -TEST(JsonRestNewTest, LoadTableResultMinimal) { - LoadTableResult r{ - .metadata_location = "", .metadata = CreateTableMetadata(), .config = {}}; - - TestJsonRoundTrip(r); - - auto j = ToJson(r); - EXPECT_FALSE(j.contains("metadata-location")); - EXPECT_TRUE(j.contains("metadata")); - EXPECT_FALSE(j.contains("config")); -} - -TEST(JsonRestNewTest, LoadTableResultWithEmptyMetadataLocation) { - LoadTableResult r{ - .metadata_location = "", .metadata = CreateTableMetadata(), .config = {}}; - - auto j = ToJson(r); - EXPECT_FALSE(j.contains("metadata-location")); -} - -TEST(JsonRestNewTest, ListNamespacesResponseWithPageToken) { - ListNamespacesResponse r{ - .next_page_token = "token123", - .namespaces = {Namespace{.levels = {"db1"}}, Namespace{.levels = {"db2", "s1"}}, - Namespace{.levels = {"db3", "s2", "sub"}}}}; - - nlohmann::json golden = R"({ - "next-page-token": "token123", - "namespaces": [ - ["db1"], ["db2","s1"], ["db3","s2","sub"] - ] - })"_json; - - TestJsonConversion(r, golden); - TestJsonRoundTrip(r); -} - -TEST(JsonRestNewTest, ListNamespacesResponseWithoutPageToken) { - ListNamespacesResponse r{ - .next_page_token = "", - .namespaces = {Namespace{.levels = {"db1"}}, Namespace{.levels = {"db2"}}}}; - - nlohmann::json golden = R"({ - "namespaces": [["db1"], ["db2"]] - })"_json; - - TestJsonConversion(r, golden); -} - -TEST(JsonRestNewTest, CreateNamespaceResponseFull) { - CreateNamespaceResponse resp{ - .namespace_ = Namespace{.levels = {"db1", "s1"}}, - .properties = std::unordered_map{{"created", "true"}}}; - - nlohmann::json golden = R"({ - "namespace": ["db1","s1"], - "properties": {"created":"true"} - })"_json; - - TestJsonConversion(resp, golden); - TestJsonRoundTrip(resp); -} - -TEST(JsonRestNewTest, CreateNamespaceResponseMinimal) { - CreateNamespaceResponse resp{.namespace_ = Namespace{.levels = {"db1", "s1"}}, - .properties = {}}; - - nlohmann::json golden = R"({"namespace":["db1","s1"]})"_json; - - TestJsonConversion(resp, golden); -} - -TEST(JsonRestNewTest, CreateNamespaceResponseIgnoreUnknown) { - nlohmann::json minimal = R"({"namespace":["db","s"]})"_json; - TestUnknownFieldIgnored(minimal); -} - -TEST(JsonRestNewTest, GetNamespaceResponseFull) { - GetNamespaceResponse r{.namespace_ = Namespace{.levels = {"prod", "analytics"}}, - .properties = std::unordered_map{ - {"owner", "team-analytics"}, {"retention", "90days"}}}; - - nlohmann::json golden = R"({ - "namespace": ["prod","analytics"], - "properties": {"owner":"team-analytics","retention":"90days"} - })"_json; - - TestJsonConversion(r, golden); - TestJsonRoundTrip(r); -} - -TEST(JsonRestNewTest, GetNamespaceResponseMinimal) { - GetNamespaceResponse r{.namespace_ = Namespace{.levels = {"db"}}, .properties = {}}; - - nlohmann::json golden = R"({"namespace":["db"]})"_json; - - TestJsonConversion(r, golden); -} - -TEST(JsonRestNewTest, UpdateNamespacePropertiesResponseFull) { - UpdateNamespacePropertiesResponse full{ - .updated = {"u1", "u2"}, .removed = {"r1"}, .missing = {"m1"}}; - - nlohmann::json g_full = R"({ - "updated": ["u1","u2"], - "removed": ["r1"], - "missing": ["m1"] - })"_json; - - TestJsonConversion(full, g_full); - TestJsonRoundTrip(full); -} - -TEST(JsonRestNewTest, UpdateNamespacePropertiesResponseMinimal) { - UpdateNamespacePropertiesResponse minimal{ - .updated = {"u"}, .removed = {}, .missing = {}}; - - nlohmann::json g_min = R"({ - "updated": ["u"], - "removed": [] - })"_json; - - TestJsonConversion(minimal, g_min); -} - -TEST(JsonRestNewTest, UpdateNamespacePropertiesResponseNoMissing) { - UpdateNamespacePropertiesResponse resp{ - .updated = {"u1"}, .removed = {"r1"}, .missing = {}}; - - nlohmann::json golden = R"({ - "updated": ["u1"], - "removed": ["r1"] - })"_json; - - TestJsonConversion(resp, golden); -} - -TEST(JsonRestNewTest, ListTablesResponseWithPageToken) { - ListTablesResponse r{.next_page_token = "token456", - .identifiers = {CreateTableIdentifier({"db1"}, "t1"), - CreateTableIdentifier({"db2", "s"}, "t2")}}; - - nlohmann::json golden = R"({ - "next-page-token": "token456", - "identifiers": [ - {"namespace":["db1"], "name":"t1"}, - {"namespace":["db2","s"], "name":"t2"} - ] - })"_json; - - TestJsonConversion(r, golden); - TestJsonRoundTrip(r); -} - -TEST(JsonRestNewTest, ListTablesResponseWithoutPageToken) { - ListTablesResponse r{.next_page_token = "", - .identifiers = {CreateTableIdentifier({"db1"}, "t1")}}; - - nlohmann::json golden = R"({ - "identifiers": [ - {"namespace":["db1"], "name":"t1"} - ] - })"_json; - - TestJsonConversion(r, golden); -} - -TEST(JsonRestNewTest, ListTablesResponseEmpty) { - ListTablesResponse r{.next_page_token = "", .identifiers = {}}; - - nlohmann::json golden = R"({ - "identifiers": [] - })"_json; - - TestJsonConversion(r, golden); + } + return lhs.next_page_token == rhs.next_page_token; } -TEST(JsonRestNewTest, ListTablesResponseIgnoreUnknown) { - nlohmann::json minimal = R"({"identifiers":[{"namespace":["db"],"name":"t"}]})"_json; - TestUnknownFieldIgnored(minimal); +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; } -TEST(JsonRestNewBoundaryTest, EmptyCollections) { - CreateNamespaceRequest request{ - .namespace_ = Namespace{.levels = {"ns"}}, - .properties = std::unordered_map{}}; +struct CreateNamespaceRequestParam { + std::string test_name; + std::string expected_json_str; + Namespace namespace_; + std::unordered_map properties; +}; - auto json = ToJson(request); - EXPECT_FALSE(json.contains("properties")); +class CreateNamespaceRequestTest + : public ::testing::TestWithParam { + protected: + void TestRoundTrip() { + const auto& param = GetParam(); - auto parsed = CreateNamespaceRequestFromJson(json); - ASSERT_TRUE(parsed.has_value()); - EXPECT_TRUE(parsed->properties.empty()); -} - -TEST(JsonRestNewBoundaryTest, InvalidJsonStructure) { - nlohmann::json invalid = nlohmann::json::array(); + // Build original object + CreateNamespaceRequest original; + original.namespace_ = param.namespace_; + original.properties = param.properties; - auto result = ListNamespacesResponseFromJson(invalid); - EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError)); + // 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"; - invalid = R"({ - "namespaces": "should be array" - })"_json; + // FromJson and verify object equality + auto result = CreateNamespaceRequestFromJson(expected_json); + ASSERT_TRUE(result.has_value()) << result.error().message; + auto& parsed = result.value(); - result = ListNamespacesResponseFromJson(invalid); - EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError)); -} - -TEST(JsonRestNewBoundaryTest, DeepNestedNamespace) { - std::vector deep_namespace; - for (int i = 0; i < 100; ++i) { - deep_namespace.push_back("level" + std::to_string(i)); + EXPECT_EQ(parsed, original); } - - CreateNamespaceRequest req{.namespace_ = Namespace{.levels = deep_namespace}, - .properties = {}}; - - TestJsonRoundTrip(req); +}; + +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 From e1c27e29393535534ec8c38e9fcae13869781e49 Mon Sep 17 00:00:00 2001 From: Li Feiyang Date: Mon, 3 Nov 2025 17:23:28 +0800 Subject: [PATCH 4/4] feat: add validator for rest api types --- src/iceberg/catalog/rest/CMakeLists.txt | 2 +- src/iceberg/catalog/rest/json_internal.cc | 6 ++ src/iceberg/catalog/rest/meson.build | 11 ++- src/iceberg/catalog/rest/validator.cc | 110 ++++++++++++++++++++++ src/iceberg/catalog/rest/validator.h | 71 ++++++++++++++ 5 files changed, 197 insertions(+), 3 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 55f1c38b7..0720375e5 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" @@ -139,6 +140,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 +167,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 +195,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 +257,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 +284,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/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/validator.cc b/src/iceberg/catalog/rest/validator.cc new file mode 100644 index 000000000..8e5d811b5 --- /dev/null +++ b/src/iceberg/catalog/rest/validator.cc @@ -0,0 +1,110 @@ +/* + * 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 "iceberg/catalog/rest/types.h" +#include "iceberg/result.h" + +namespace iceberg::rest { + +// 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(std::from_range, request.removals); + + auto common = + request.updates | std::views::keys | + std::views::filter([&](const std::string& k) { return remove_set.contains(k); }) | + std::ranges::to>(); + + if (!common.empty()) { + std::string keys; + bool first = true; + std::ranges::for_each(common, [&](const std::string& s) { + 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..bf3c24326 --- /dev/null +++ b/src/iceberg/catalog/rest/validator.h @@ -0,0 +1,71 @@ +/* + * 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: + // 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