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 {

Reply via email to