This is an automated email from the ASF dual-hosted git repository.
gangwu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new c24102e feat: add json serde for REST Catalog request/response models
(#251)
c24102e is described below
commit c24102e19869a581955fa9a268f5beead377b912
Author: Li Feiyang <[email protected]>
AuthorDate: Mon Nov 3 21:53:44 2025 +0800
feat: add json serde for REST Catalog request/response models (#251)
---
src/iceberg/catalog/rest/CMakeLists.txt | 2 +-
src/iceberg/catalog/rest/json_internal.cc | 285 +++++++++
src/iceberg/catalog/rest/json_internal.h | 101 +++
src/iceberg/catalog/rest/meson.build | 2 +-
src/iceberg/catalog/rest/types.h | 15 +-
src/iceberg/json_internal.cc | 30 +
src/iceberg/json_internal.h | 26 +
src/iceberg/test/CMakeLists.txt | 3 +-
src/iceberg/test/meson.build | 5 +-
src/iceberg/test/rest_json_internal_test.cc | 919 ++++++++++++++++++++++++++++
src/iceberg/util/json_util_internal.h | 9 +
11 files changed, 1380 insertions(+), 17 deletions(-)
diff --git a/src/iceberg/catalog/rest/CMakeLists.txt
b/src/iceberg/catalog/rest/CMakeLists.txt
index 2f9c2f0..38d8972 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 0000000..55f1c38
--- /dev/null
+++ b/src/iceberg/catalog/rest/json_internal.cc
@@ -0,0 +1,285 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "iceberg/catalog/rest/json_internal.h"
+
+#include <string>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "iceberg/catalog/rest/types.h"
+#include "iceberg/json_internal.h"
+#include "iceberg/table_identifier.h"
+#include "iceberg/util/json_util_internal.h"
+#include "iceberg/util/macros.h"
+
+namespace iceberg::rest {
+
+namespace {
+
+// REST API JSON field constants
+constexpr std::string_view kNamespace = "namespace";
+constexpr std::string_view kNamespaces = "namespaces";
+constexpr std::string_view kProperties = "properties";
+constexpr std::string_view kRemovals = "removals";
+constexpr std::string_view kUpdates = "updates";
+constexpr std::string_view kUpdated = "updated";
+constexpr std::string_view kRemoved = "removed";
+constexpr std::string_view kMissing = "missing";
+constexpr std::string_view kNextPageToken = "next-page-token";
+constexpr std::string_view kName = "name";
+constexpr std::string_view kLocation = "location";
+constexpr std::string_view kSchema = "schema";
+constexpr std::string_view kPartitionSpec = "partition-spec";
+constexpr std::string_view kWriteOrder = "write-order";
+constexpr std::string_view kStageCreate = "stage-create";
+constexpr std::string_view kMetadataLocation = "metadata-location";
+constexpr std::string_view kOverwrite = "overwrite";
+constexpr std::string_view kSource = "source";
+constexpr std::string_view kDestination = "destination";
+constexpr std::string_view kMetadata = "metadata";
+constexpr std::string_view kConfig = "config";
+constexpr std::string_view kIdentifiers = "identifiers";
+
+} // namespace
+
+nlohmann::json ToJson(const CreateNamespaceRequest& request) {
+ nlohmann::json json;
+ json[kNamespace] = request.namespace_.levels;
+ if (!request.properties.empty()) {
+ json[kProperties] = request.properties;
+ }
+ return json;
+}
+
+Result<CreateNamespaceRequest> CreateNamespaceRequestFromJson(
+ const nlohmann::json& json) {
+ CreateNamespaceRequest request;
+ ICEBERG_ASSIGN_OR_RAISE(request.namespace_.levels,
+ GetJsonValue<std::vector<std::string>>(json,
kNamespace));
+ ICEBERG_ASSIGN_OR_RAISE(
+ request.properties,
+ GetJsonValueOrDefault<decltype(request.properties)>(json, kProperties));
+ return request;
+}
+
+nlohmann::json ToJson(const UpdateNamespacePropertiesRequest& request) {
+ // Initialize as an empty object so that when all optional fields are absent
we return
+ // {} instead of null
+ nlohmann::json json = nlohmann::json::object();
+ if (!request.removals.empty()) {
+ json[kRemovals] = request.removals;
+ }
+ if (!request.updates.empty()) {
+ json[kUpdates] = request.updates;
+ }
+ return json;
+}
+
+Result<UpdateNamespacePropertiesRequest>
UpdateNamespacePropertiesRequestFromJson(
+ const nlohmann::json& json) {
+ UpdateNamespacePropertiesRequest request;
+ ICEBERG_ASSIGN_OR_RAISE(
+ request.removals, GetJsonValueOrDefault<std::vector<std::string>>(json,
kRemovals));
+ ICEBERG_ASSIGN_OR_RAISE(
+ request.updates, GetJsonValueOrDefault<decltype(request.updates)>(json,
kUpdates));
+ return request;
+}
+
+nlohmann::json ToJson(const RegisterTableRequest& request) {
+ nlohmann::json json;
+ json[kName] = request.name;
+ json[kMetadataLocation] = request.metadata_location;
+ if (request.overwrite) {
+ json[kOverwrite] = request.overwrite;
+ }
+ return json;
+}
+
+Result<RegisterTableRequest> RegisterTableRequestFromJson(const
nlohmann::json& json) {
+ RegisterTableRequest request;
+ ICEBERG_ASSIGN_OR_RAISE(request.name, GetJsonValue<std::string>(json,
kName));
+ ICEBERG_ASSIGN_OR_RAISE(request.metadata_location,
+ GetJsonValue<std::string>(json, kMetadataLocation));
+ ICEBERG_ASSIGN_OR_RAISE(request.overwrite,
+ GetJsonValueOrDefault<bool>(json, kOverwrite,
false));
+ return request;
+}
+
+nlohmann::json ToJson(const RenameTableRequest& request) {
+ nlohmann::json json;
+ json[kSource] = ToJson(request.source);
+ json[kDestination] = ToJson(request.destination);
+ return json;
+}
+
+Result<RenameTableRequest> RenameTableRequestFromJson(const nlohmann::json&
json) {
+ RenameTableRequest request;
+ ICEBERG_ASSIGN_OR_RAISE(auto source_json, GetJsonValue<nlohmann::json>(json,
kSource));
+ ICEBERG_ASSIGN_OR_RAISE(request.source,
TableIdentifierFromJson(source_json));
+ ICEBERG_ASSIGN_OR_RAISE(auto dest_json,
+ GetJsonValue<nlohmann::json>(json, kDestination));
+ ICEBERG_ASSIGN_OR_RAISE(request.destination,
TableIdentifierFromJson(dest_json));
+ return request;
+}
+
+// LoadTableResult (used by CreateTableResponse, LoadTableResponse)
+nlohmann::json ToJson(const LoadTableResult& result) {
+ nlohmann::json json;
+ if (!result.metadata_location.empty()) {
+ json[kMetadataLocation] = result.metadata_location;
+ }
+ json[kMetadata] = ToJson(*result.metadata);
+ if (!result.config.empty()) {
+ json[kConfig] = result.config;
+ }
+ return json;
+}
+
+Result<LoadTableResult> LoadTableResultFromJson(const nlohmann::json& json) {
+ LoadTableResult result;
+ ICEBERG_ASSIGN_OR_RAISE(result.metadata_location,
+ GetJsonValueOrDefault<std::string>(json,
kMetadataLocation));
+ ICEBERG_ASSIGN_OR_RAISE(auto metadata_json,
+ GetJsonValue<nlohmann::json>(json, kMetadata));
+ ICEBERG_ASSIGN_OR_RAISE(result.metadata,
TableMetadataFromJson(metadata_json));
+ ICEBERG_ASSIGN_OR_RAISE(
+ result.config, (GetJsonValueOrDefault<std::unordered_map<std::string,
std::string>>(
+ json, kConfig)));
+ return result;
+}
+
+nlohmann::json ToJson(const ListNamespacesResponse& response) {
+ nlohmann::json json;
+ if (!response.next_page_token.empty()) {
+ json[kNextPageToken] = response.next_page_token;
+ }
+ nlohmann::json namespaces = nlohmann::json::array();
+ for (const auto& ns : response.namespaces) {
+ namespaces.push_back(ToJson(ns));
+ }
+ json[kNamespaces] = std::move(namespaces);
+ return json;
+}
+
+Result<ListNamespacesResponse> ListNamespacesResponseFromJson(
+ const nlohmann::json& json) {
+ ListNamespacesResponse response;
+ ICEBERG_ASSIGN_OR_RAISE(response.next_page_token,
+ GetJsonValueOrDefault<std::string>(json,
kNextPageToken));
+ ICEBERG_ASSIGN_OR_RAISE(auto namespaces_json,
+ GetJsonValue<nlohmann::json>(json, kNamespaces));
+ for (const auto& ns_json : namespaces_json) {
+ ICEBERG_ASSIGN_OR_RAISE(auto ns, NamespaceFromJson(ns_json));
+ response.namespaces.push_back(std::move(ns));
+ }
+ return response;
+}
+
+nlohmann::json ToJson(const CreateNamespaceResponse& response) {
+ nlohmann::json json;
+ json[kNamespace] = response.namespace_.levels;
+ if (!response.properties.empty()) {
+ json[kProperties] = response.properties;
+ }
+ return json;
+}
+
+Result<CreateNamespaceResponse> CreateNamespaceResponseFromJson(
+ const nlohmann::json& json) {
+ CreateNamespaceResponse response;
+ ICEBERG_ASSIGN_OR_RAISE(response.namespace_.levels,
+ GetJsonValue<std::vector<std::string>>(json,
kNamespace));
+ ICEBERG_ASSIGN_OR_RAISE(
+ response.properties,
+ GetJsonValueOrDefault<decltype(response.properties)>(json, kProperties));
+ return response;
+}
+
+nlohmann::json ToJson(const GetNamespaceResponse& response) {
+ nlohmann::json json;
+ json[kNamespace] = response.namespace_.levels;
+ if (!response.properties.empty()) {
+ json[kProperties] = response.properties;
+ }
+ return json;
+}
+
+Result<GetNamespaceResponse> GetNamespaceResponseFromJson(const
nlohmann::json& json) {
+ GetNamespaceResponse response;
+ ICEBERG_ASSIGN_OR_RAISE(response.namespace_.levels,
+ GetJsonValue<std::vector<std::string>>(json,
kNamespace));
+ ICEBERG_ASSIGN_OR_RAISE(
+ response.properties,
+ GetJsonValueOrDefault<decltype(response.properties)>(json, kProperties));
+ return response;
+}
+
+nlohmann::json ToJson(const UpdateNamespacePropertiesResponse& response) {
+ nlohmann::json json;
+ json[kUpdated] = response.updated;
+ json[kRemoved] = response.removed;
+ if (!response.missing.empty()) {
+ json[kMissing] = response.missing;
+ }
+ return json;
+}
+
+Result<UpdateNamespacePropertiesResponse>
UpdateNamespacePropertiesResponseFromJson(
+ const nlohmann::json& json) {
+ UpdateNamespacePropertiesResponse response;
+ ICEBERG_ASSIGN_OR_RAISE(response.updated,
+ GetJsonValue<std::vector<std::string>>(json,
kUpdated));
+ ICEBERG_ASSIGN_OR_RAISE(response.removed,
+ GetJsonValue<std::vector<std::string>>(json,
kRemoved));
+ ICEBERG_ASSIGN_OR_RAISE(
+ response.missing, GetJsonValueOrDefault<std::vector<std::string>>(json,
kMissing));
+ return response;
+}
+
+nlohmann::json ToJson(const ListTablesResponse& response) {
+ nlohmann::json json;
+ if (!response.next_page_token.empty()) {
+ json[kNextPageToken] = response.next_page_token;
+ }
+ nlohmann::json identifiers_json = nlohmann::json::array();
+ for (const auto& identifier : response.identifiers) {
+ identifiers_json.push_back(ToJson(identifier));
+ }
+ json[kIdentifiers] = identifiers_json;
+ return json;
+}
+
+Result<ListTablesResponse> ListTablesResponseFromJson(const nlohmann::json&
json) {
+ ListTablesResponse response;
+ ICEBERG_ASSIGN_OR_RAISE(response.next_page_token,
+ GetJsonValueOrDefault<std::string>(json,
kNextPageToken));
+ ICEBERG_ASSIGN_OR_RAISE(auto identifiers_json,
+ GetJsonValue<nlohmann::json>(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 0000000..11b567a
--- /dev/null
+++ b/src/iceberg/catalog/rest/json_internal.h
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#pragma once
+
+#include <nlohmann/json_fwd.hpp>
+
+#include "iceberg/catalog/rest/types.h"
+#include "iceberg/result.h"
+
+namespace iceberg::rest {
+
+/// \brief Serializes a `ListNamespacesResponse` object to JSON.
+ICEBERG_REST_EXPORT nlohmann::json ToJson(const ListNamespacesResponse&
response);
+
+/// \brief Deserializes a JSON object into a `ListNamespacesResponse` object.
+ICEBERG_REST_EXPORT Result<ListNamespacesResponse>
ListNamespacesResponseFromJson(
+ const nlohmann::json& json);
+
+/// \brief Serializes a `CreateNamespaceRequest` object to JSON.
+ICEBERG_REST_EXPORT nlohmann::json ToJson(const CreateNamespaceRequest&
request);
+
+/// \brief Deserializes a JSON object into a `CreateNamespaceRequest` object.
+ICEBERG_REST_EXPORT Result<CreateNamespaceRequest>
CreateNamespaceRequestFromJson(
+ const nlohmann::json& json);
+
+/// \brief Serializes a `CreateNamespaceResponse` object to JSON.
+ICEBERG_REST_EXPORT nlohmann::json ToJson(const CreateNamespaceResponse&
response);
+
+/// \brief Deserializes a JSON object into a `CreateNamespaceResponse` object.
+ICEBERG_REST_EXPORT Result<CreateNamespaceResponse>
CreateNamespaceResponseFromJson(
+ const nlohmann::json& json);
+
+/// \brief Serializes a `GetNamespaceResponse` object to JSON.
+ICEBERG_REST_EXPORT nlohmann::json ToJson(const GetNamespaceResponse&
response);
+
+/// \brief Deserializes a JSON object into a `GetNamespaceResponse` object.
+ICEBERG_REST_EXPORT Result<GetNamespaceResponse> GetNamespaceResponseFromJson(
+ const nlohmann::json& json);
+
+/// \brief Serializes an `UpdateNamespacePropertiesRequest` object to JSON.
+ICEBERG_REST_EXPORT nlohmann::json ToJson(
+ const UpdateNamespacePropertiesRequest& request);
+
+/// \brief Deserializes a JSON object into an
`UpdateNamespacePropertiesRequest` object.
+ICEBERG_REST_EXPORT Result<UpdateNamespacePropertiesRequest>
+UpdateNamespacePropertiesRequestFromJson(const nlohmann::json& json);
+
+/// \brief Serializes an `UpdateNamespacePropertiesResponse` object to JSON.
+ICEBERG_REST_EXPORT nlohmann::json ToJson(
+ const UpdateNamespacePropertiesResponse& response);
+
+/// \brief Deserializes a JSON object into an
`UpdateNamespacePropertiesResponse` object.
+ICEBERG_REST_EXPORT Result<UpdateNamespacePropertiesResponse>
+UpdateNamespacePropertiesResponseFromJson(const nlohmann::json& json);
+
+/// \brief Serializes a `ListTablesResponse` object to JSON.
+ICEBERG_REST_EXPORT nlohmann::json ToJson(const ListTablesResponse& response);
+
+/// \brief Deserializes a JSON object into a `ListTablesResponse` object.
+ICEBERG_REST_EXPORT Result<ListTablesResponse> ListTablesResponseFromJson(
+ const nlohmann::json& json);
+
+/// \brief Serializes a `LoadTableResult` object to JSON.
+ICEBERG_REST_EXPORT nlohmann::json ToJson(const LoadTableResult& result);
+
+/// \brief Deserializes a JSON object into a `LoadTableResult` object.
+ICEBERG_REST_EXPORT Result<LoadTableResult> LoadTableResultFromJson(
+ const nlohmann::json& json);
+
+/// \brief Serializes a `RegisterTableRequest` object to JSON.
+ICEBERG_REST_EXPORT nlohmann::json ToJson(const RegisterTableRequest& request);
+
+/// \brief Deserializes a JSON object into a `RegisterTableRequest` object.
+ICEBERG_REST_EXPORT Result<RegisterTableRequest> RegisterTableRequestFromJson(
+ const nlohmann::json& json);
+
+/// \brief Serializes a `RenameTableRequest` object to JSON.
+ICEBERG_REST_EXPORT nlohmann::json ToJson(const RenameTableRequest& request);
+
+/// \brief Deserializes a JSON object into a `RenameTableRequest` object.
+ICEBERG_REST_EXPORT Result<RenameTableRequest> 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 9d8a7d3..5f1f635 100644
--- a/src/iceberg/catalog/rest/meson.build
+++ b/src/iceberg/catalog/rest/meson.build
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-iceberg_rest_sources = files('rest_catalog.cc')
+iceberg_rest_sources = files('json_internal.cc', 'rest_catalog.cc')
# cpr does not export symbols, so on Windows it must
# be used as a static lib
cpr_needs_static = (
diff --git a/src/iceberg/catalog/rest/types.h b/src/iceberg/catalog/rest/types.h
index 4c50ab2..11411cd 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<std::string, std::string> updates;
};
-/// \brief Request to create a table.
-struct ICEBERG_REST_EXPORT CreateTableRequest {
- std::string name; // required
- std::string location;
- std::shared_ptr<Schema> schema; // required
- std::shared_ptr<PartitionSpec> partition_spec;
- std::shared_ptr<SortOrder> write_order;
- std::optional<bool> stage_create;
- std::unordered_map<std::string, std::string> properties;
-};
-
/// \brief Request to register a table.
struct ICEBERG_REST_EXPORT RegisterTableRequest {
std::string name; // required
@@ -75,8 +64,8 @@ using PageToken = std::string;
/// \brief Result body for table create/load/register APIs.
struct ICEBERG_REST_EXPORT LoadTableResult {
- std::optional<std::string> metadata_location;
- std::shared_ptr<TableMetadata> metadata; // required // required
+ std::string metadata_location;
+ std::shared_ptr<TableMetadata> metadata; // required
std::unordered_map<std::string, std::string> config;
// TODO(Li Feiyang): Add std::shared_ptr<StorageCredential>
storage_credential;
};
diff --git a/src/iceberg/json_internal.cc b/src/iceberg/json_internal.cc
index 0ad5461..1bad8cc 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<std::unique_ptr<NameMapping>>
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<TableIdentifier> TableIdentifierFromJson(const nlohmann::json& json) {
+ TableIdentifier identifier;
+ ICEBERG_ASSIGN_OR_RAISE(
+ identifier.ns.levels,
+ GetJsonValueOrDefault<std::vector<std::string>>(json, kNamespace));
+ ICEBERG_ASSIGN_OR_RAISE(identifier.name, GetJsonValue<std::string>(json,
kName));
+
+ return identifier;
+}
+
+nlohmann::json ToJson(const Namespace& ns) { return ns.levels; }
+
+Result<Namespace> 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<std::vector<std::string>>(json));
+ return ns;
+}
+
} // namespace iceberg
diff --git a/src/iceberg/json_internal.h b/src/iceberg/json_internal.h
index d5eb5bc..894bc6e 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<std::unique_ptr<NameMapping>> 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<TableIdentifier> 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<Namespace> NamespaceFromJson(const nlohmann::json& json);
+
} // namespace iceberg
diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt
index 100287f..fd8cbc9 100644
--- a/src/iceberg/test/CMakeLists.txt
+++ b/src/iceberg/test/CMakeLists.txt
@@ -151,7 +151,8 @@ if(ICEBERG_BUILD_BUNDLE)
endif()
if(ICEBERG_BUILD_REST)
- add_iceberg_test(rest_catalog_test SOURCES rest_catalog_test.cc)
+ add_iceberg_test(rest_catalog_test SOURCES rest_catalog_test.cc
+ rest_json_internal_test.cc)
target_link_libraries(rest_catalog_test PRIVATE iceberg_rest_static)
target_include_directories(rest_catalog_test PRIVATE
${cpp-httplib_SOURCE_DIR})
endif()
diff --git a/src/iceberg/test/meson.build b/src/iceberg/test/meson.build
index 89cb357..22ed4bd 100644
--- a/src/iceberg/test/meson.build
+++ b/src/iceberg/test/meson.build
@@ -87,7 +87,10 @@ if get_option('rest').enabled()
cpp_httplib_dep = dependency('cpp-httplib')
iceberg_tests += {
'rest_catalog_test': {
- 'sources': files('rest_catalog_test.cc'),
+ 'sources': files(
+ 'rest_catalog_test.cc',
+ 'rest_json_internal_test.cc',
+ ),
'dependencies': [iceberg_rest_dep, cpp_httplib_dep],
},
}
diff --git a/src/iceberg/test/rest_json_internal_test.cc
b/src/iceberg/test/rest_json_internal_test.cc
new file mode 100644
index 0000000..c042f7f
--- /dev/null
+++ b/src/iceberg/test/rest_json_internal_test.cc
@@ -0,0 +1,919 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include <algorithm>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <nlohmann/json.hpp>
+
+#include "iceberg/catalog/rest/json_internal.h"
+#include "iceberg/catalog/rest/types.h"
+#include "iceberg/result.h"
+#include "iceberg/table_identifier.h"
+#include "iceberg/test/matchers.h"
+
+namespace iceberg::rest {
+
+bool operator==(const CreateNamespaceRequest& lhs, const
CreateNamespaceRequest& rhs) {
+ return lhs.namespace_.levels == rhs.namespace_.levels &&
+ lhs.properties == rhs.properties;
+}
+
+bool operator==(const UpdateNamespacePropertiesRequest& lhs,
+ const UpdateNamespacePropertiesRequest& rhs) {
+ return lhs.removals == rhs.removals && lhs.updates == rhs.updates;
+}
+
+bool operator==(const RegisterTableRequest& lhs, const RegisterTableRequest&
rhs) {
+ return lhs.name == rhs.name && lhs.metadata_location ==
rhs.metadata_location &&
+ lhs.overwrite == rhs.overwrite;
+}
+
+bool operator==(const CreateNamespaceResponse& lhs, const
CreateNamespaceResponse& rhs) {
+ return lhs.namespace_.levels == rhs.namespace_.levels &&
+ lhs.properties == rhs.properties;
+}
+
+bool operator==(const GetNamespaceResponse& lhs, const GetNamespaceResponse&
rhs) {
+ return lhs.namespace_.levels == rhs.namespace_.levels &&
+ lhs.properties == rhs.properties;
+}
+
+bool operator==(const ListNamespacesResponse& lhs, const
ListNamespacesResponse& rhs) {
+ if (lhs.namespaces.size() != rhs.namespaces.size()) return false;
+ for (size_t i = 0; i < lhs.namespaces.size(); ++i) {
+ if (lhs.namespaces[i].levels != rhs.namespaces[i].levels) return false;
+ }
+ return lhs.next_page_token == rhs.next_page_token;
+}
+
+bool operator==(const UpdateNamespacePropertiesResponse& lhs,
+ const UpdateNamespacePropertiesResponse& rhs) {
+ return lhs.updated == rhs.updated && lhs.removed == rhs.removed &&
+ lhs.missing == rhs.missing;
+}
+
+bool operator==(const ListTablesResponse& lhs, const ListTablesResponse& rhs) {
+ if (lhs.identifiers.size() != rhs.identifiers.size()) return false;
+ for (size_t i = 0; i < lhs.identifiers.size(); ++i) {
+ if (lhs.identifiers[i].ns.levels != rhs.identifiers[i].ns.levels ||
+ lhs.identifiers[i].name != rhs.identifiers[i].name) {
+ return false;
+ }
+ }
+ return lhs.next_page_token == rhs.next_page_token;
+}
+
+bool operator==(const RenameTableRequest& lhs, const RenameTableRequest& rhs) {
+ return lhs.source.ns.levels == rhs.source.ns.levels &&
+ lhs.source.name == rhs.source.name &&
+ lhs.destination.ns.levels == rhs.destination.ns.levels &&
+ lhs.destination.name == rhs.destination.name;
+}
+
+struct CreateNamespaceRequestParam {
+ std::string test_name;
+ std::string expected_json_str;
+ Namespace namespace_;
+ std::unordered_map<std::string, std::string> properties;
+};
+
+class CreateNamespaceRequestTest
+ : public ::testing::TestWithParam<CreateNamespaceRequestParam> {
+ protected:
+ void TestRoundTrip() {
+ const auto& param = GetParam();
+
+ // Build original object
+ CreateNamespaceRequest original;
+ original.namespace_ = param.namespace_;
+ original.properties = param.properties;
+
+ // ToJson and verify JSON string
+ auto json = ToJson(original);
+ auto expected_json = nlohmann::json::parse(param.expected_json_str);
+ EXPECT_EQ(json, expected_json) << "ToJson mismatch";
+
+ // FromJson and verify object equality
+ auto result = CreateNamespaceRequestFromJson(expected_json);
+ ASSERT_TRUE(result.has_value()) << result.error().message;
+ auto& parsed = result.value();
+
+ EXPECT_EQ(parsed, original);
+ }
+};
+
+TEST_P(CreateNamespaceRequestTest, RoundTrip) { TestRoundTrip(); }
+
+INSTANTIATE_TEST_SUITE_P(
+ CreateNamespaceRequestCases, CreateNamespaceRequestTest,
+ ::testing::Values(
+ // Full request with properties
+ CreateNamespaceRequestParam{
+ .test_name = "FullRequest",
+ .expected_json_str =
+
R"({"namespace":["accounting","tax"],"properties":{"owner":"Hank"}})",
+ .namespace_ = Namespace{{"accounting", "tax"}},
+ .properties = {{"owner", "Hank"}},
+ },
+ // Request with empty properties (omit properties field when empty)
+ CreateNamespaceRequestParam{
+ .test_name = "EmptyProperties",
+ .expected_json_str = R"({"namespace":["accounting","tax"]})",
+ .namespace_ = Namespace{{"accounting", "tax"}},
+ .properties = {},
+ },
+ // Request with empty namespace
+ CreateNamespaceRequestParam{
+ .test_name = "EmptyNamespace",
+ .expected_json_str = R"({"namespace":[]})",
+ .namespace_ = Namespace{},
+ .properties = {},
+ }),
+ [](const ::testing::TestParamInfo<CreateNamespaceRequestParam>& 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<std::string>({"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<std::string>({"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<std::string, std::string> properties;
+};
+
+class CreateNamespaceResponseTest
+ : public ::testing::TestWithParam<CreateNamespaceResponseParam> {
+ 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<CreateNamespaceResponseParam>& 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<std::string, std::string> properties;
+};
+
+class GetNamespaceResponseTest
+ : public ::testing::TestWithParam<GetNamespaceResponseParam> {
+ 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<GetNamespaceResponseParam>& 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<Namespace> namespaces;
+ std::string next_page_token;
+};
+
+class ListNamespacesResponseTest
+ : public ::testing::TestWithParam<ListNamespacesResponseParam> {
+ 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<ListNamespacesResponseParam>& 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<std::string> removals;
+ std::unordered_map<std::string, std::string> updates;
+};
+
+class UpdateNamespacePropertiesRequestTest
+ : public ::testing::TestWithParam<UpdateNamespacePropertiesRequestParam> {
+ 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<UpdateNamespacePropertiesRequestParam>&
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<std::string> updated;
+ std::vector<std::string> removed;
+ std::vector<std::string> missing;
+};
+
+class UpdateNamespacePropertiesResponseTest
+ : public ::testing::TestWithParam<UpdateNamespacePropertiesResponseParam> {
+ 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<UpdateNamespacePropertiesResponseParam>&
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<std::string>({"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<TableIdentifier> identifiers;
+ std::string next_page_token;
+};
+
+class ListTablesResponseTest : public
::testing::TestWithParam<ListTablesResponseParam> {
+ 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<ListTablesResponseParam>& 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<RenameTableRequestParam> {
+ 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<RenameTableRequestParam>& 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<RegisterTableRequestParam> {
+ 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<RegisterTableRequestParam>& info) {
+ return info.param.test_name;
+ });
+
+TEST(RegisterTableRequestTest, DeserializeWithoutDefaults) {
+ // Overwrite missing (defaults to false)
+ std::string json1 =
+ R"({"name":"table1","metadata-location":"s3://bucket/metadata.json"})";
+ auto result1 = RegisterTableRequestFromJson(nlohmann::json::parse(json1));
+ ASSERT_TRUE(result1.has_value());
+ EXPECT_FALSE(result1.value().overwrite);
+}
+
+TEST(RegisterTableRequestTest, InvalidRequests) {
+ std::string json_missing_name =
R"({"metadata-location":"s3://bucket/metadata.json"})";
+ auto result1 =
RegisterTableRequestFromJson(nlohmann::json::parse(json_missing_name));
+ EXPECT_FALSE(result1.has_value());
+ EXPECT_THAT(result1, IsError(ErrorKind::kJsonParseError));
+ EXPECT_EQ(result1.error().message,
+ "Missing 'name' in
{\"metadata-location\":\"s3://bucket/metadata.json\"}");
+
+ std::string json_missing_location = R"({"name":"table1"})";
+ auto result2 =
+
RegisterTableRequestFromJson(nlohmann::json::parse(json_missing_location));
+ EXPECT_FALSE(result2.has_value());
+ EXPECT_THAT(result2, IsError(ErrorKind::kJsonParseError));
+ EXPECT_EQ(result2.error().message,
+ "Missing 'metadata-location' in {\"name\":\"table1\"}");
+
+ std::string json_empty = R"({})";
+ auto result3 =
RegisterTableRequestFromJson(nlohmann::json::parse(json_empty));
+ EXPECT_FALSE(result3.has_value());
+ EXPECT_THAT(result3, IsError(ErrorKind::kJsonParseError));
+ EXPECT_EQ(result3.error().message, "Missing 'name' in {}");
+}
+
+} // namespace iceberg::rest
diff --git a/src/iceberg/util/json_util_internal.h
b/src/iceberg/util/json_util_internal.h
index 81d17b0..6205ad1 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 <typename T>
+Result<T> GetTypedJsonValue(const nlohmann::json& json) {
+ try {
+ return json.get<T>();
+ } catch (const std::exception& ex) {
+ return JsonParseError("Failed to parse {}: {}", SafeDumpJson(json),
ex.what());
+ }
+}
+
template <typename T>
Result<T> GetJsonValueImpl(const nlohmann::json& json, std::string_view key) {
try {