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 0dbb593  refactor: simplify test structure for rest catalog models 
(#290)
0dbb593 is described below

commit 0dbb593df6cd3564efdbd283dcc1db3c299beeb1
Author: Gang Wu <[email protected]>
AuthorDate: Tue Nov 4 21:27:02 2025 +0800

    refactor: simplify test structure for rest catalog models (#290)
    
    - simplified rest catalog model definitions.
    - added common functions to operate json ser/de.
    - refactored cases to be able to be shared.
---
 src/iceberg/catalog/rest/json_internal.cc   |   72 +-
 src/iceberg/catalog/rest/json_internal.h    |   97 +--
 src/iceberg/test/rest_catalog_test.cc       |    6 +-
 src/iceberg/test/rest_json_internal_test.cc | 1097 +++++++++++----------------
 src/iceberg/util/json_util_internal.h       |   15 +
 5 files changed, 541 insertions(+), 746 deletions(-)

diff --git a/src/iceberg/catalog/rest/json_internal.cc 
b/src/iceberg/catalog/rest/json_internal.cc
index 55f1c38..452de7a 100644
--- a/src/iceberg/catalog/rest/json_internal.cc
+++ b/src/iceberg/catalog/rest/json_internal.cc
@@ -65,9 +65,7 @@ constexpr std::string_view kIdentifiers = "identifiers";
 nlohmann::json ToJson(const CreateNamespaceRequest& request) {
   nlohmann::json json;
   json[kNamespace] = request.namespace_.levels;
-  if (!request.properties.empty()) {
-    json[kProperties] = request.properties;
-  }
+  SetContainerField(json, kProperties, request.properties);
   return json;
 }
 
@@ -83,15 +81,9 @@ Result<CreateNamespaceRequest> 
CreateNamespaceRequestFromJson(
 }
 
 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;
-  }
+  SetContainerField(json, kRemovals, request.removals);
+  SetContainerField(json, kUpdates, request.updates);
   return json;
 }
 
@@ -145,13 +137,9 @@ Result<RenameTableRequest> 
RenameTableRequestFromJson(const nlohmann::json& json
 // LoadTableResult (used by CreateTableResponse, LoadTableResponse)
 nlohmann::json ToJson(const LoadTableResult& result) {
   nlohmann::json json;
-  if (!result.metadata_location.empty()) {
-    json[kMetadataLocation] = result.metadata_location;
-  }
+  SetOptionalStringField(json, kMetadataLocation, result.metadata_location);
   json[kMetadata] = ToJson(*result.metadata);
-  if (!result.config.empty()) {
-    json[kConfig] = result.config;
-  }
+  SetContainerField(json, kConfig, result.config);
   return json;
 }
 
@@ -162,17 +150,14 @@ Result<LoadTableResult> LoadTableResultFromJson(const 
nlohmann::json& json) {
   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)));
+  ICEBERG_ASSIGN_OR_RAISE(result.config,
+                          GetJsonValueOrDefault<decltype(result.config)>(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;
-  }
+  SetOptionalStringField(json, kNextPageToken, response.next_page_token);
   nlohmann::json namespaces = nlohmann::json::array();
   for (const auto& ns : response.namespaces) {
     namespaces.push_back(ToJson(ns));
@@ -198,9 +183,7 @@ Result<ListNamespacesResponse> 
ListNamespacesResponseFromJson(
 nlohmann::json ToJson(const CreateNamespaceResponse& response) {
   nlohmann::json json;
   json[kNamespace] = response.namespace_.levels;
-  if (!response.properties.empty()) {
-    json[kProperties] = response.properties;
-  }
+  SetContainerField(json, kProperties, response.properties);
   return json;
 }
 
@@ -218,9 +201,7 @@ Result<CreateNamespaceResponse> 
CreateNamespaceResponseFromJson(
 nlohmann::json ToJson(const GetNamespaceResponse& response) {
   nlohmann::json json;
   json[kNamespace] = response.namespace_.levels;
-  if (!response.properties.empty()) {
-    json[kProperties] = response.properties;
-  }
+  SetContainerField(json, kProperties, response.properties);
   return json;
 }
 
@@ -238,19 +219,17 @@ 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;
-  }
+  SetContainerField(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.updated, GetJsonValueOrDefault<std::vector<std::string>>(json, 
kUpdated));
+  ICEBERG_ASSIGN_OR_RAISE(
+      response.removed, GetJsonValueOrDefault<std::vector<std::string>>(json, 
kRemoved));
   ICEBERG_ASSIGN_OR_RAISE(
       response.missing, GetJsonValueOrDefault<std::vector<std::string>>(json, 
kMissing));
   return response;
@@ -258,9 +237,7 @@ Result<UpdateNamespacePropertiesResponse> 
UpdateNamespacePropertiesResponseFromJ
 
 nlohmann::json ToJson(const ListTablesResponse& response) {
   nlohmann::json json;
-  if (!response.next_page_token.empty()) {
-    json[kNextPageToken] = response.next_page_token;
-  }
+  SetOptionalStringField(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));
@@ -282,4 +259,21 @@ Result<ListTablesResponse> 
ListTablesResponseFromJson(const nlohmann::json& json
   return response;
 }
 
+#define ICEBERG_DEFINE_FROM_JSON(Model)                       \
+  template <>                                                 \
+  Result<Model> FromJson<Model>(const nlohmann::json& json) { \
+    return Model##FromJson(json);                             \
+  }
+
+ICEBERG_DEFINE_FROM_JSON(ListNamespacesResponse)
+ICEBERG_DEFINE_FROM_JSON(CreateNamespaceRequest)
+ICEBERG_DEFINE_FROM_JSON(CreateNamespaceResponse)
+ICEBERG_DEFINE_FROM_JSON(GetNamespaceResponse)
+ICEBERG_DEFINE_FROM_JSON(UpdateNamespacePropertiesRequest)
+ICEBERG_DEFINE_FROM_JSON(UpdateNamespacePropertiesResponse)
+ICEBERG_DEFINE_FROM_JSON(ListTablesResponse)
+ICEBERG_DEFINE_FROM_JSON(LoadTableResult)
+ICEBERG_DEFINE_FROM_JSON(RegisterTableRequest)
+ICEBERG_DEFINE_FROM_JSON(RenameTableRequest)
+
 }  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/json_internal.h 
b/src/iceberg/catalog/rest/json_internal.h
index 11b567a..129e883 100644
--- a/src/iceberg/catalog/rest/json_internal.h
+++ b/src/iceberg/catalog/rest/json_internal.h
@@ -21,81 +21,36 @@
 
 #include <nlohmann/json_fwd.hpp>
 
+#include "iceberg/catalog/rest/iceberg_rest_export.h"
 #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);
+template <typename Model>
+Result<Model> FromJson(const nlohmann::json& json);
+
+#define ICEBERG_DECLARE_JSON_SERDE(Model)                                      
  \
+  ICEBERG_REST_EXPORT Result<Model> Model##FromJson(const nlohmann::json& 
json); \
+                                                                               
  \
+  template <>                                                                  
  \
+  ICEBERG_REST_EXPORT Result<Model> FromJson(const nlohmann::json& json);      
  \
+                                                                               
  \
+  ICEBERG_REST_EXPORT nlohmann::json ToJson(const Model& model);
+
+/// \note Don't forget to add `ICEBERG_DEFINE_FROM_JSON` to the end of
+/// `json_internal.cc` to define the `FromJson` function for the model.
+ICEBERG_DECLARE_JSON_SERDE(ListNamespacesResponse)
+ICEBERG_DECLARE_JSON_SERDE(CreateNamespaceRequest)
+ICEBERG_DECLARE_JSON_SERDE(CreateNamespaceResponse)
+ICEBERG_DECLARE_JSON_SERDE(GetNamespaceResponse)
+ICEBERG_DECLARE_JSON_SERDE(UpdateNamespacePropertiesRequest)
+ICEBERG_DECLARE_JSON_SERDE(UpdateNamespacePropertiesResponse)
+ICEBERG_DECLARE_JSON_SERDE(ListTablesResponse)
+ICEBERG_DECLARE_JSON_SERDE(LoadTableResult)
+ICEBERG_DECLARE_JSON_SERDE(RegisterTableRequest)
+ICEBERG_DECLARE_JSON_SERDE(RenameTableRequest)
+
+#undef ICEBERG_DECLARE_JSON_SERDE
 
 }  // namespace iceberg::rest
diff --git a/src/iceberg/test/rest_catalog_test.cc 
b/src/iceberg/test/rest_catalog_test.cc
index b10f8c5..fda9ef6 100644
--- a/src/iceberg/test/rest_catalog_test.cc
+++ b/src/iceberg/test/rest_catalog_test.cc
@@ -51,7 +51,7 @@ class RestCatalogIntegrationTest : public ::testing::Test {
   std::thread server_thread_;
 };
 
-TEST_F(RestCatalogIntegrationTest, GetConfigSuccessfully) {
+TEST_F(RestCatalogIntegrationTest, DISABLED_GetConfigSuccessfully) {
   server_->Get("/v1/config", [](const httplib::Request&, httplib::Response& 
res) {
     res.status = 200;
     res.set_content(R"({"warehouse": "s3://test-bucket"})", 
"application/json");
@@ -68,7 +68,7 @@ TEST_F(RestCatalogIntegrationTest, GetConfigSuccessfully) {
   EXPECT_EQ(json_body["warehouse"], "s3://test-bucket");
 }
 
-TEST_F(RestCatalogIntegrationTest, ListNamespacesReturnsMultipleResults) {
+TEST_F(RestCatalogIntegrationTest, 
DISABLED_ListNamespacesReturnsMultipleResults) {
   server_->Get("/v1/namespaces", [](const httplib::Request&, 
httplib::Response& res) {
     res.status = 200;
     res.set_content(R"({
@@ -93,7 +93,7 @@ TEST_F(RestCatalogIntegrationTest, 
ListNamespacesReturnsMultipleResults) {
   EXPECT_THAT(json_body["namespaces"][0][0], "accounting");
 }
 
-TEST_F(RestCatalogIntegrationTest, HandlesServerError) {
+TEST_F(RestCatalogIntegrationTest, DISABLED_HandlesServerError) {
   server_->Get("/v1/config", [](const httplib::Request&, httplib::Response& 
res) {
     res.status = 500;
     res.set_content("Internal Server Error", "text/plain");
diff --git a/src/iceberg/test/rest_json_internal_test.cc 
b/src/iceberg/test/rest_json_internal_test.cc
index c042f7f..d95f6a2 100644
--- a/src/iceberg/test/rest_json_internal_test.cc
+++ b/src/iceberg/test/rest_json_internal_test.cc
@@ -34,6 +34,7 @@
 
 namespace iceberg::rest {
 
+// TODO(gangwu): perhaps add these equality operators to the types themselves?
 bool operator==(const CreateNamespaceRequest& lhs, const 
CreateNamespaceRequest& rhs) {
   return lhs.namespace_.levels == rhs.namespace_.levels &&
          lhs.properties == rhs.properties;
@@ -91,39 +92,100 @@ bool operator==(const RenameTableRequest& lhs, const 
RenameTableRequest& rhs) {
          lhs.destination.name == rhs.destination.name;
 }
 
-struct CreateNamespaceRequestParam {
+// Test parameter structure for roundtrip tests
+template <typename Model>
+struct JsonRoundTripParam {
   std::string test_name;
   std::string expected_json_str;
-  Namespace namespace_;
-  std::unordered_map<std::string, std::string> properties;
+  Model model;
 };
 
-class CreateNamespaceRequestTest
-    : public ::testing::TestWithParam<CreateNamespaceRequestParam> {
+// Generic test class for roundtrip tests
+template <typename Model>
+class JsonRoundTripTest : public 
::testing::TestWithParam<JsonRoundTripParam<Model>> {
+  using Base = ::testing::TestWithParam<JsonRoundTripParam<Model>>;
+
  protected:
   void TestRoundTrip() {
-    const auto& param = GetParam();
-
-    // Build original object
-    CreateNamespaceRequest original;
-    original.namespace_ = param.namespace_;
-    original.properties = param.properties;
+    const auto& param = Base::GetParam();
 
-    // ToJson and verify JSON string
-    auto json = ToJson(original);
+    // ToJson
+    auto json = ToJson(param.model);
     auto expected_json = nlohmann::json::parse(param.expected_json_str);
-    EXPECT_EQ(json, expected_json) << "ToJson mismatch";
+    ASSERT_EQ(json, expected_json) << "ToJson mismatch";
+
+    // FromJson
+    auto result = FromJson<Model>(expected_json);
+    ASSERT_THAT(result, IsOk()) << result.error().message;
+    auto parsed = std::move(result.value());
+    ASSERT_EQ(parsed, param.model);
+  }
+};
+
+#define DECLARE_ROUNDTRIP_TEST(Model)             \
+  using Model##Test = JsonRoundTripTest<Model>;   \
+  using Model##Param = JsonRoundTripParam<Model>; \
+  TEST_P(Model##Test, RoundTrip) { TestRoundTrip(); }
+
+// Invalid JSON test parameter structure
+template <typename Model>
+struct JsonInvalidParam {
+  std::string test_name;
+  std::string invalid_json_str;
+  std::string expected_error_message;
+};
+
+// Generic test class for invalid JSON deserialization
+template <typename Model>
+class JsonInvalidTest : public 
::testing::TestWithParam<JsonInvalidParam<Model>> {
+  using Base = ::testing::TestWithParam<JsonInvalidParam<Model>>;
+
+ protected:
+  void TestInvalidJson() {
+    const auto& param = Base::GetParam();
+
+    auto result = 
FromJson<Model>(nlohmann::json::parse(param.invalid_json_str));
+    ASSERT_THAT(result, IsError(ErrorKind::kJsonParseError));
+    ASSERT_THAT(result, HasErrorMessage(param.expected_error_message))
+        << result.error().message;
+  }
+};
 
-    // FromJson and verify object equality
-    auto result = CreateNamespaceRequestFromJson(expected_json);
-    ASSERT_TRUE(result.has_value()) << result.error().message;
-    auto& parsed = result.value();
+#define DECLARE_INVALID_TEST(Model)                    \
+  using Model##InvalidTest = JsonInvalidTest<Model>;   \
+  using Model##InvalidParam = JsonInvalidParam<Model>; \
+  TEST_P(Model##InvalidTest, InvalidJson) { TestInvalidJson(); }
 
-    EXPECT_EQ(parsed, original);
+// Deserialization test parameter structure
+template <typename Model>
+struct JsonDeserParam {
+  std::string test_name;
+  std::string json_str;
+  Model expected_model;
+};
+
+// Generic test class for deserialization tests (FromJson only)
+template <typename Model>
+class JsonDeserTest : public ::testing::TestWithParam<JsonDeserParam<Model>> {
+  using Base = ::testing::TestWithParam<JsonDeserParam<Model>>;
+
+ protected:
+  void TestDeserialize() {
+    const auto& param = Base::GetParam();
+
+    auto result = FromJson<Model>(nlohmann::json::parse(param.json_str));
+    ASSERT_THAT(result, IsOk()) << result.error().message;
+    auto parsed = std::move(result.value());
+    ASSERT_EQ(parsed, param.expected_model);
   }
 };
 
-TEST_P(CreateNamespaceRequestTest, RoundTrip) { TestRoundTrip(); }
+#define DECLARE_DESERIALIZE_TEST(Model)                  \
+  using Model##DeserializeTest = JsonDeserTest<Model>;   \
+  using Model##DeserializeParam = JsonDeserParam<Model>; \
+  TEST_P(Model##DeserializeTest, Deserialize) { TestDeserialize(); }
+
+DECLARE_ROUNDTRIP_TEST(CreateNamespaceRequest)
 
 INSTANTIATE_TEST_SUITE_P(
     CreateNamespaceRequestCases, CreateNamespaceRequestTest,
@@ -133,787 +195,556 @@ INSTANTIATE_TEST_SUITE_P(
             .test_name = "FullRequest",
             .expected_json_str =
                 
R"({"namespace":["accounting","tax"],"properties":{"owner":"Hank"}})",
-            .namespace_ = Namespace{{"accounting", "tax"}},
-            .properties = {{"owner", "Hank"}},
-        },
+            .model = {.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 = {},
+            .model = {.namespace_ = Namespace{{"accounting", "tax"}}},
         },
         // Request with empty namespace
         CreateNamespaceRequestParam{
             .test_name = "EmptyNamespace",
             .expected_json_str = R"({"namespace":[]})",
-            .namespace_ = Namespace{},
-            .properties = {},
+            .model = {.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 {}");
-}
+DECLARE_INVALID_TEST(CreateNamespaceRequest)
 
-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);
+INSTANTIATE_TEST_SUITE_P(
+    CreateNamespaceRequestInvalidCases, CreateNamespaceRequestInvalidTest,
+    ::testing::Values(
+        // Incorrect type for namespace field
+        CreateNamespaceRequestInvalidParam{
+            .test_name = "WrongNamespaceType",
+            .invalid_json_str = 
R"({"namespace":"accounting%1Ftax","properties":null})",
+            .expected_error_message = "type must be array, but is string"},
+        // Incorrect type for properties field
+        CreateNamespaceRequestInvalidParam{
+            .test_name = "WrongPropertiesType",
+            .invalid_json_str = 
R"({"namespace":["accounting","tax"],"properties":[]})",
+            .expected_error_message = "type must be object, but is array"},
+        // Misspelled required field
+        CreateNamespaceRequestInvalidParam{
+            .test_name = "MisspelledKeys",
+            .invalid_json_str =
+                
R"({"namepsace":["accounting","tax"],"propertiezzzz":{"owner":"Hank"}})",
+            .expected_error_message = "Missing 'namespace'"},
+        // Empty JSON object
+        CreateNamespaceRequestInvalidParam{
+            .test_name = "EmptyJson",
+            .invalid_json_str = R"({})",
+            .expected_error_message = "Missing 'namespace'"}),
+    [](const ::testing::TestParamInfo<CreateNamespaceRequestInvalidParam>& 
info) {
+      return info.param.test_name;
+    });
 
-    auto result = CreateNamespaceResponseFromJson(expected_json);
-    ASSERT_TRUE(result.has_value()) << result.error().message;
-    auto& parsed = result.value();
+DECLARE_DESERIALIZE_TEST(CreateNamespaceRequest)
 
-    EXPECT_EQ(parsed, original);
-  }
-};
+INSTANTIATE_TEST_SUITE_P(
+    CreateNamespaceRequestDeserializeCases, 
CreateNamespaceRequestDeserializeTest,
+    ::testing::Values(
+        // Properties field is null (should deserialize to empty map)
+        CreateNamespaceRequestDeserializeParam{
+            .test_name = "NullProperties",
+            .json_str = 
R"({"namespace":["accounting","tax"],"properties":null})",
+            .expected_model = {.namespace_ = Namespace{{"accounting", 
"tax"}}}},
+        // Properties field is missing (should deserialize to empty map)
+        CreateNamespaceRequestDeserializeParam{
+            .test_name = "MissingProperties",
+            .json_str = R"({"namespace":["accounting","tax"]})",
+            .expected_model = {.namespace_ = Namespace{{"accounting", 
"tax"}}}}),
+    [](const ::testing::TestParamInfo<CreateNamespaceRequestDeserializeParam>& 
info) {
+      return info.param.test_name;
+    });
 
-TEST_P(CreateNamespaceResponseTest, RoundTrip) { TestRoundTrip(); }
+DECLARE_ROUNDTRIP_TEST(CreateNamespaceResponse)
 
 INSTANTIATE_TEST_SUITE_P(
     CreateNamespaceResponseCases, CreateNamespaceResponseTest,
     ::testing::Values(
+        // Full response with namespace and properties
         CreateNamespaceResponseParam{
             .test_name = "FullResponse",
             .expected_json_str =
                 
R"({"namespace":["accounting","tax"],"properties":{"owner":"Hank"}})",
-            .namespace_ = Namespace{{"accounting", "tax"}},
-            .properties = {{"owner", "Hank"}},
-        },
+            .model = {.namespace_ = Namespace{{"accounting", "tax"}},
+                      .properties = {{"owner", "Hank"}}}},
+        // Response with empty properties (omit properties field when empty)
         CreateNamespaceResponseParam{
             .test_name = "EmptyProperties",
             .expected_json_str = R"({"namespace":["accounting","tax"]})",
-            .namespace_ = Namespace{{"accounting", "tax"}},
-            .properties = {},
-        },
+            .model = {.namespace_ = Namespace{{"accounting", "tax"}}}},
+        // Response with empty namespace
         CreateNamespaceResponseParam{.test_name = "EmptyNamespace",
                                      .expected_json_str = 
R"({"namespace":[]})",
-                                     .namespace_ = Namespace{},
-                                     .properties = {}}),
+                                     .model = {.namespace_ = Namespace{}}}),
     [](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 {}");
-}
+DECLARE_DESERIALIZE_TEST(CreateNamespaceResponse)
 
-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);
+INSTANTIATE_TEST_SUITE_P(
+    CreateNamespaceResponseDeserializeCases, 
CreateNamespaceResponseDeserializeTest,
+    ::testing::Values(
+        // Properties field is missing (should deserialize to empty map)
+        CreateNamespaceResponseDeserializeParam{
+            .test_name = "MissingProperties",
+            .json_str = R"({"namespace":["accounting","tax"]})",
+            .expected_model = {.namespace_ = Namespace{{"accounting", 
"tax"}}}},
+        // Properties field is null (should deserialize to empty map)
+        CreateNamespaceResponseDeserializeParam{
+            .test_name = "NullProperties",
+            .json_str = 
R"({"namespace":["accounting","tax"],"properties":null})",
+            .expected_model = {.namespace_ = Namespace{{"accounting", 
"tax"}}}}),
+    [](const 
::testing::TestParamInfo<CreateNamespaceResponseDeserializeParam>& info) {
+      return info.param.test_name;
+    });
 
-    auto result = GetNamespaceResponseFromJson(expected_json);
-    ASSERT_TRUE(result.has_value()) << result.error().message;
-    auto& parsed = result.value();
+DECLARE_INVALID_TEST(CreateNamespaceResponse)
 
-    EXPECT_EQ(parsed, original);
-  }
-};
+INSTANTIATE_TEST_SUITE_P(
+    CreateNamespaceResponseInvalidCases, CreateNamespaceResponseInvalidTest,
+    ::testing::Values(
+        // Incorrect type for namespace field
+        CreateNamespaceResponseInvalidParam{
+            .test_name = "WrongNamespaceType",
+            .invalid_json_str = 
R"({"namespace":"accounting%1Ftax","properties":null})",
+            .expected_error_message = "type must be array, but is string"},
+        // Incorrect type for properties field
+        CreateNamespaceResponseInvalidParam{
+            .test_name = "WrongPropertiesType",
+            .invalid_json_str = 
R"({"namespace":["accounting","tax"],"properties":[]})",
+            .expected_error_message = "type must be object, but is array"},
+        // Empty JSON object
+        CreateNamespaceResponseInvalidParam{
+            .test_name = "EmptyJson",
+            .invalid_json_str = R"({})",
+            .expected_error_message = "Missing 'namespace'"}),
+    [](const ::testing::TestParamInfo<CreateNamespaceResponseInvalidParam>& 
info) {
+      return info.param.test_name;
+    });
 
-TEST_P(GetNamespaceResponseTest, RoundTrip) { TestRoundTrip(); }
+DECLARE_ROUNDTRIP_TEST(GetNamespaceResponse)
 
 INSTANTIATE_TEST_SUITE_P(
     GetNamespaceResponseCases, GetNamespaceResponseTest,
     ::testing::Values(
+        // Full response with namespace and properties
         GetNamespaceResponseParam{
             .test_name = "FullResponse",
             .expected_json_str =
                 
R"({"namespace":["accounting","tax"],"properties":{"owner":"Hank"}})",
-            .namespace_ = Namespace{{"accounting", "tax"}},
-            .properties = {{"owner", "Hank"}}},
+            .model = {.namespace_ = Namespace{{"accounting", "tax"}},
+                      .properties = {{"owner", "Hank"}}}},
+        // Response with empty properties (omit properties field when empty)
         GetNamespaceResponseParam{
             .test_name = "EmptyProperties",
             .expected_json_str = R"({"namespace":["accounting","tax"]})",
-            .namespace_ = Namespace{{"accounting", "tax"}},
-            .properties = {}}),
+            .model = {.namespace_ = Namespace{{"accounting", "tax"}}}}),
     [](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;
+DECLARE_DESERIALIZE_TEST(GetNamespaceResponse)
 
-    auto json = ToJson(original);
-    auto expected_json = nlohmann::json::parse(param.expected_json_str);
-    EXPECT_EQ(json, expected_json);
+INSTANTIATE_TEST_SUITE_P(
+    GetNamespaceResponseDeserializeCases, GetNamespaceResponseDeserializeTest,
+    ::testing::Values(
+        // Properties field is null (should deserialize to empty map)
+        GetNamespaceResponseDeserializeParam{
+            .test_name = "NullProperties",
+            .json_str = 
R"({"namespace":["accounting","tax"],"properties":null})",
+            .expected_model = {.namespace_ = Namespace{{"accounting", 
"tax"}}}}),
+    [](const ::testing::TestParamInfo<GetNamespaceResponseDeserializeParam>& 
info) {
+      return info.param.test_name;
+    });
 
-    auto result = ListNamespacesResponseFromJson(expected_json);
-    ASSERT_TRUE(result.has_value()) << result.error().message;
-    auto& parsed = result.value();
+DECLARE_INVALID_TEST(GetNamespaceResponse)
 
-    EXPECT_EQ(parsed, original);
-  }
-};
+INSTANTIATE_TEST_SUITE_P(
+    GetNamespaceResponseInvalidCases, GetNamespaceResponseInvalidTest,
+    ::testing::Values(
+        // Incorrect type for namespace field
+        GetNamespaceResponseInvalidParam{
+            .test_name = "WrongNamespaceType",
+            .invalid_json_str = 
R"({"namespace":"accounting%1Ftax","properties":null})",
+            .expected_error_message = "type must be array, but is string"},
+        // Incorrect type for properties field
+        GetNamespaceResponseInvalidParam{
+            .test_name = "WrongPropertiesType",
+            .invalid_json_str = 
R"({"namespace":["accounting","tax"],"properties":[]})",
+            .expected_error_message = "type must be object, but is array"},
+        // Empty JSON object
+        GetNamespaceResponseInvalidParam{
+            .test_name = "EmptyJson",
+            .invalid_json_str = R"({})",
+            .expected_error_message = "Missing 'namespace'"}),
+    [](const ::testing::TestParamInfo<GetNamespaceResponseInvalidParam>& info) 
{
+      return info.param.test_name;
+    });
 
-TEST_P(ListNamespacesResponseTest, RoundTrip) { TestRoundTrip(); }
+DECLARE_ROUNDTRIP_TEST(ListNamespacesResponse)
 
 INSTANTIATE_TEST_SUITE_P(
     ListNamespacesResponseCases, ListNamespacesResponseTest,
     ::testing::Values(
+        // Full response with multiple namespaces
         ListNamespacesResponseParam{
             .test_name = "FullResponse",
             .expected_json_str = R"({"namespaces":[["accounting"],["tax"]]})",
-            .namespaces = {Namespace{{"accounting"}}, Namespace{{"tax"}}},
-            .next_page_token = ""},
+            .model = {.next_page_token = "",
+                      .namespaces = {Namespace{{"accounting"}}, 
Namespace{{"tax"}}}}},
+        // Response with empty namespaces
         ListNamespacesResponseParam{.test_name = "EmptyNamespaces",
                                     .expected_json_str = 
R"({"namespaces":[]})",
-                                    .namespaces = {},
-                                    .next_page_token = ""},
+                                    .model = {.next_page_token = ""}},
+        // Response with 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"}),
+            .model = {.next_page_token = "token",
+                      .namespaces = {Namespace{{"accounting"}}, 
Namespace{{"tax"}}}}}),
     [](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();
+DECLARE_INVALID_TEST(ListNamespacesResponse)
 
-    EXPECT_EQ(parsed, original);
-  }
-};
+INSTANTIATE_TEST_SUITE_P(
+    ListNamespacesResponseInvalidCases, ListNamespacesResponseInvalidTest,
+    ::testing::Values(
+        // Incorrect type for namespaces field
+        ListNamespacesResponseInvalidParam{
+            .test_name = "WrongNamespacesType",
+            .invalid_json_str = R"({"namespaces":"accounting"})",
+            .expected_error_message = "Cannot parse namespace from non-array"},
+        // Empty JSON object
+        ListNamespacesResponseInvalidParam{
+            .test_name = "EmptyJson",
+            .invalid_json_str = R"({})",
+            .expected_error_message = "Missing 'namespaces'"}),
+    [](const ::testing::TestParamInfo<ListNamespacesResponseInvalidParam>& 
info) {
+      return info.param.test_name;
+    });
 
-TEST_P(UpdateNamespacePropertiesRequestTest, RoundTrip) { TestRoundTrip(); }
+DECLARE_ROUNDTRIP_TEST(UpdateNamespacePropertiesRequest)
 
 INSTANTIATE_TEST_SUITE_P(
     UpdateNamespacePropertiesRequestCases, 
UpdateNamespacePropertiesRequestTest,
     ::testing::Values(
+        // Full request with both removals and updates
         UpdateNamespacePropertiesRequestParam{
             .test_name = "FullRequest",
             .expected_json_str =
                 R"({"removals":["foo","bar"],"updates":{"owner":"Hank"}})",
-            .removals = {"foo", "bar"},
-            .updates = {{"owner", "Hank"}}},
+            .model = {.removals = {"foo", "bar"}, .updates = {{"owner", 
"Hank"}}}},
+        // Request with only updates
         UpdateNamespacePropertiesRequestParam{
             .test_name = "OnlyUpdates",
             .expected_json_str = R"({"updates":{"owner":"Hank"}})",
-            .removals = {},
-            .updates = {{"owner", "Hank"}}},
+            .model = {.updates = {{"owner", "Hank"}}}},
+        // Request with only removals
         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 = {}}),
+            .model = {.removals = {"foo", "bar"}}},
+        // Request with all empty fields
+        UpdateNamespacePropertiesRequestParam{
+            .test_name = "AllEmpty", .expected_json_str = R"({})", .model = 
{}}),
     [](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;
+DECLARE_DESERIALIZE_TEST(UpdateNamespacePropertiesRequest)
 
-    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);
-  }
-};
+INSTANTIATE_TEST_SUITE_P(
+    UpdateNamespacePropertiesRequestDeserializeCases,
+    UpdateNamespacePropertiesRequestDeserializeTest,
+    ::testing::Values(
+        // Removals is null (should deserialize to empty vector)
+        UpdateNamespacePropertiesRequestDeserializeParam{
+            .test_name = "NullRemovals",
+            .json_str = R"({"removals":null,"updates":{"owner":"Hank"}})",
+            .expected_model = {.updates = {{"owner", "Hank"}}}},
+        // Removals is missing (should deserialize to empty vector)
+        UpdateNamespacePropertiesRequestDeserializeParam{
+            .test_name = "MissingRemovals",
+            .json_str = R"({"updates":{"owner":"Hank"}})",
+            .expected_model = {.updates = {{"owner", "Hank"}}}},
+        // Updates is null (should deserialize to empty map)
+        UpdateNamespacePropertiesRequestDeserializeParam{
+            .test_name = "NullUpdates",
+            .json_str = R"({"removals":["foo","bar"],"updates":null})",
+            .expected_model = {.removals = {"foo", "bar"}}},
+        // All fields missing (should deserialize to empty)
+        UpdateNamespacePropertiesRequestDeserializeParam{
+            .test_name = "AllMissing", .json_str = R"({})", .expected_model = 
{}}),
+    [](const 
::testing::TestParamInfo<UpdateNamespacePropertiesRequestDeserializeParam>&
+           info) { return info.param.test_name; });
+
+DECLARE_INVALID_TEST(UpdateNamespacePropertiesRequest)
 
-TEST_P(UpdateNamespacePropertiesResponseTest, RoundTrip) { TestRoundTrip(); }
+INSTANTIATE_TEST_SUITE_P(
+    UpdateNamespacePropertiesRequestInvalidCases,
+    UpdateNamespacePropertiesRequestInvalidTest,
+    ::testing::Values(
+        // Incorrect type for removals field
+        UpdateNamespacePropertiesRequestInvalidParam{
+            .test_name = "WrongRemovalsType",
+            .invalid_json_str =
+                R"({"removals":{"foo":"bar"},"updates":{"owner":"Hank"}})",
+            .expected_error_message = "type must be array, but is object"},
+        // Incorrect type for updates field
+        UpdateNamespacePropertiesRequestInvalidParam{
+            .test_name = "WrongUpdatesType",
+            .invalid_json_str = 
R"({"removals":["foo","bar"],"updates":["owner"]})",
+            .expected_error_message = "type must be object, but is array"}),
+    [](const 
::testing::TestParamInfo<UpdateNamespacePropertiesRequestInvalidParam>&
+           info) { return info.param.test_name; });
+
+DECLARE_ROUNDTRIP_TEST(UpdateNamespacePropertiesResponse)
 
 INSTANTIATE_TEST_SUITE_P(
     UpdateNamespacePropertiesResponseCases, 
UpdateNamespacePropertiesResponseTest,
     ::testing::Values(
+        // Full response with updated, removed, and missing fields
         UpdateNamespacePropertiesResponseParam{
             .test_name = "FullResponse",
             .expected_json_str =
                 R"({"removed":["foo"],"updated":["owner"],"missing":["bar"]})",
-            .updated = {"owner"},
-            .removed = {"foo"},
-            .missing = {"bar"}},
+            .model = {.updated = {"owner"}, .removed = {"foo"}, .missing = 
{"bar"}}},
+        // Response with only updated field
         UpdateNamespacePropertiesResponseParam{
             .test_name = "OnlyUpdated",
             .expected_json_str = R"({"removed":[],"updated":["owner"]})",
-            .updated = {"owner"},
-            .removed = {},
-            .missing = {}},
+            .model = {.updated = {"owner"}}},
+        // Response with only removed field
         UpdateNamespacePropertiesResponseParam{
             .test_name = "OnlyRemoved",
             .expected_json_str = R"({"removed":["foo"],"updated":[]})",
-            .updated = {},
-            .removed = {"foo"},
-            .missing = {}},
+            .model = {.removed = {"foo"}}},
+        // Response with only missing field
         UpdateNamespacePropertiesResponseParam{
             .test_name = "OnlyMissing",
             .expected_json_str = 
R"({"removed":[],"updated":[],"missing":["bar"]})",
-            .updated = {},
-            .removed = {},
-            .missing = {"bar"}},
+            .model = {.missing = {"bar"}}},
+        // Response with all empty fields
         UpdateNamespacePropertiesResponseParam{
             .test_name = "AllEmpty",
             .expected_json_str = R"({"removed":[],"updated":[]})",
-            .updated = {},
-            .removed = {},
-            .missing = {}}),
+            .model = {}}),
     [](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();
+DECLARE_DESERIALIZE_TEST(UpdateNamespacePropertiesResponse)
 
-    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);
-  }
-};
+INSTANTIATE_TEST_SUITE_P(
+    UpdateNamespacePropertiesResponseDeserializeCases,
+    UpdateNamespacePropertiesResponseDeserializeTest,
+    ::testing::Values(
+        // Only updated and removed present, missing is optional
+        UpdateNamespacePropertiesResponseDeserializeParam{
+            .test_name = "MissingOptional",
+            .json_str = R"({"updated":["owner"],"removed":[]})",
+            .expected_model = {.updated = {"owner"}}},
+        // All fields are missing
+        UpdateNamespacePropertiesResponseDeserializeParam{
+            .test_name = "AllMissing", .json_str = R"({})", .expected_model = 
{}}),
+    [](const 
::testing::TestParamInfo<UpdateNamespacePropertiesResponseDeserializeParam>&
+           info) { return info.param.test_name; });
+
+DECLARE_INVALID_TEST(UpdateNamespacePropertiesResponse)
 
-TEST_P(ListTablesResponseTest, RoundTrip) { TestRoundTrip(); }
+INSTANTIATE_TEST_SUITE_P(
+    UpdateNamespacePropertiesResponseInvalidCases,
+    UpdateNamespacePropertiesResponseInvalidTest,
+    ::testing::Values(
+        // Incorrect type for removed field
+        UpdateNamespacePropertiesResponseInvalidParam{
+            .test_name = "WrongRemovedType",
+            .invalid_json_str =
+                
R"({"removed":{"foo":true},"updated":["owner"],"missing":["bar"]})",
+            .expected_error_message = "type must be array, but is object"},
+        // Incorrect type for updated field
+        UpdateNamespacePropertiesResponseInvalidParam{
+            .test_name = "WrongUpdatedType",
+            .invalid_json_str = R"({"updated":"owner","missing":["bar"]})",
+            .expected_error_message = "type must be array, but is string"},
+        // Valid top-level (array) types, but at least one entry in the list 
is not the
+        // expected type
+        UpdateNamespacePropertiesResponseInvalidParam{
+            .test_name = "InvalidArrayEntryType",
+            .invalid_json_str =
+                R"({"removed":["foo", "bar", 
123456],"updated":["owner"],"missing":["bar"]})",
+            .expected_error_message = " type must be string, but is number"}),
+    [](const 
::testing::TestParamInfo<UpdateNamespacePropertiesResponseInvalidParam>&
+           info) { return info.param.test_name; });
+
+DECLARE_ROUNDTRIP_TEST(ListTablesResponse)
 
 INSTANTIATE_TEST_SUITE_P(
     ListTablesResponseCases, ListTablesResponseTest,
     ::testing::Values(
+        // Full response with table identifiers
         ListTablesResponseParam{
             .test_name = "FullResponse",
             .expected_json_str =
                 
R"({"identifiers":[{"namespace":["accounting","tax"],"name":"paid"}]})",
-            .identifiers = {TableIdentifier{Namespace{{"accounting", "tax"}}, 
"paid"}},
-            .next_page_token = ""},
+            .model = {.next_page_token = "",
+                      .identifiers = {TableIdentifier{Namespace{{"accounting", 
"tax"}},
+                                                      "paid"}}}},
+        // Response with empty identifiers
         ListTablesResponseParam{.test_name = "EmptyIdentifiers",
                                 .expected_json_str = R"({"identifiers":[]})",
-                                .identifiers = {},
-                                .next_page_token = ""},
+                                .model = {.next_page_token = ""}},
+        // Response with 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"}),
+            .model = {.next_page_token = "token",
+                      .identifiers = {TableIdentifier{Namespace{{"accounting", 
"tax"}},
+                                                      "paid"}}}}),
     [](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");
-}
+DECLARE_INVALID_TEST(ListTablesResponse)
 
-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);
-  }
-};
+INSTANTIATE_TEST_SUITE_P(
+    ListTablesResponseInvalidCases, ListTablesResponseInvalidTest,
+    ::testing::Values(
+        // Incorrect type for identifiers field (string instead of array)
+        ListTablesResponseInvalidParam{
+            .test_name = "WrongIdentifiersType",
+            .invalid_json_str = R"({"identifiers":"accounting%1Ftax"})",
+            .expected_error_message = "Missing 'name'"},
+        // Empty JSON object
+        ListTablesResponseInvalidParam{.test_name = "EmptyJson",
+                                       .invalid_json_str = R"({})",
+                                       .expected_error_message = "Missing 
'identifiers'"},
+        // Invalid identifier with wrong namespace type
+        ListTablesResponseInvalidParam{
+            .test_name = "InvalidIdentifierNamespaceType",
+            .invalid_json_str =
+                
R"({"identifiers":[{"namespace":"accounting.tax","name":"paid"}]})",
+            .expected_error_message = "type must be array, but is string"}),
+    [](const ::testing::TestParamInfo<ListTablesResponseInvalidParam>& info) {
+      return info.param.test_name;
+    });
 
-TEST_P(RenameTableRequestTest, RoundTrip) { TestRoundTrip(); }
+DECLARE_ROUNDTRIP_TEST(RenameTableRequest)
 
 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"}}),
+    ::testing::Values(
+        // Full request with source and destination table identifiers
+        RenameTableRequestParam{
+            .test_name = "FullRequest",
+            .expected_json_str =
+                
R"({"source":{"namespace":["accounting","tax"],"name":"paid"},"destination":{"namespace":["accounting","tax"],"name":"paid_2022"}})",
+            .model = {.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();
+DECLARE_INVALID_TEST(RenameTableRequest)
 
-    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);
-  }
-};
+INSTANTIATE_TEST_SUITE_P(
+    RenameTableRequestInvalidCases, RenameTableRequestInvalidTest,
+    ::testing::Values(
+        // Source table name is null
+        RenameTableRequestInvalidParam{
+            .test_name = "SourceNameNull",
+            .invalid_json_str =
+                
R"({"source":{"namespace":["accounting","tax"],"name":null},"destination":{"namespace":["accounting","tax"],"name":"paid_2022"}})",
+            .expected_error_message = "Missing 'name'"},
+        // Destination table name is null
+        RenameTableRequestInvalidParam{
+            .test_name = "DestinationNameNull",
+            .invalid_json_str =
+                
R"({"source":{"namespace":["accounting","tax"],"name":"paid"},"destination":{"namespace":["accounting","tax"],"name":null}})",
+            .expected_error_message = "Missing 'name'"},
+        // Empty JSON object
+        RenameTableRequestInvalidParam{.test_name = "EmptyJson",
+                                       .invalid_json_str = R"({})",
+                                       .expected_error_message = "Missing 
'source'"}),
+    [](const ::testing::TestParamInfo<RenameTableRequestInvalidParam>& info) {
+      return info.param.test_name;
+    });
 
-TEST_P(RegisterTableRequestTest, RoundTrip) { TestRoundTrip(); }
+DECLARE_ROUNDTRIP_TEST(RegisterTableRequest)
 
 INSTANTIATE_TEST_SUITE_P(
     RegisterTableRequestCases, RegisterTableRequestTest,
     ::testing::Values(
+        // Request with overwrite set to true
         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},
+            .model = {.name = "table1",
+                      .metadata_location = "s3://bucket/metadata.json",
+                      .overwrite = true}},
+        // Request without overwrite field (defaults to false, omitted in 
serialization)
         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}),
+            .model = {.name = "table1",
+                      .metadata_location = "s3://bucket/metadata.json"}}),
     [](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);
-}
+DECLARE_DESERIALIZE_TEST(RegisterTableRequest)
 
-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 {}");
-}
+INSTANTIATE_TEST_SUITE_P(
+    RegisterTableRequestDeserializeCases, RegisterTableRequestDeserializeTest,
+    ::testing::Values(
+        // Overwrite missing (should default to false)
+        RegisterTableRequestDeserializeParam{
+            .test_name = "MissingOverwrite",
+            .json_str =
+                
R"({"name":"table1","metadata-location":"s3://bucket/metadata.json"})",
+            .expected_model = {.name = "table1",
+                               .metadata_location = 
"s3://bucket/metadata.json",
+                               .overwrite = false}}),
+    [](const ::testing::TestParamInfo<RegisterTableRequestDeserializeParam>& 
info) {
+      return info.param.test_name;
+    });
+
+DECLARE_INVALID_TEST(RegisterTableRequest)
+
+INSTANTIATE_TEST_SUITE_P(
+    RegisterTableRequestInvalidCases, RegisterTableRequestInvalidTest,
+    ::testing::Values(
+        // Missing required name field
+        RegisterTableRequestInvalidParam{
+            .test_name = "MissingName",
+            .invalid_json_str = 
R"({"metadata-location":"s3://bucket/metadata.json"})",
+            .expected_error_message = "Missing 'name' in"},
+        // Missing required metadata-location field
+        RegisterTableRequestInvalidParam{
+            .test_name = "MissingMetadataLocation",
+            .invalid_json_str = R"({"name":"table1"})",
+            .expected_error_message = "Missing 'metadata-location'"},
+        // Empty JSON object
+        RegisterTableRequestInvalidParam{.test_name = "EmptyJson",
+                                         .invalid_json_str = R"({})",
+                                         .expected_error_message = "Missing 
'name'"}),
+    [](const ::testing::TestParamInfo<RegisterTableRequestInvalidParam>& info) 
{
+      return info.param.test_name;
+    });
 
 }  // namespace iceberg::rest
diff --git a/src/iceberg/util/json_util_internal.h 
b/src/iceberg/util/json_util_internal.h
index 6205ad1..65764c4 100644
--- a/src/iceberg/util/json_util_internal.h
+++ b/src/iceberg/util/json_util_internal.h
@@ -39,6 +39,21 @@ void SetOptionalField(nlohmann::json& json, std::string_view 
key,
   }
 }
 
+template <typename T>
+  requires requires(const T& t) { t.empty(); }
+void SetContainerField(nlohmann::json& json, std::string_view key, const T& 
value) {
+  if (!value.empty()) {
+    json[key] = value;
+  }
+}
+
+inline void SetOptionalStringField(nlohmann::json& json, std::string_view key,
+                                   const std::string& value) {
+  if (!value.empty()) {
+    json[key] = value;
+  }
+}
+
 inline std::string SafeDumpJson(const nlohmann::json& json) {
   return json.dump(/*indent=*/-1, /*indent_char=*/' ', /*ensure_ascii=*/false,
                    nlohmann::detail::error_handler_t::ignore);

Reply via email to