diff --git a/google/cloud/storage/google_cloud_cpp_storage.bzl b/google/cloud/storage/google_cloud_cpp_storage.bzl index 41bb9f343edbb..a220f85850621 100644 --- a/google/cloud/storage/google_cloud_cpp_storage.bzl +++ b/google/cloud/storage/google_cloud_cpp_storage.bzl @@ -134,6 +134,7 @@ google_cloud_cpp_storage_hdrs = [ "oauth2/refreshing_credentials_wrapper.h", "oauth2/service_account_credentials.h", "object_access_control.h", + "object_contexts.h", "object_metadata.h", "object_read_stream.h", "object_retention.h", @@ -251,6 +252,7 @@ google_cloud_cpp_storage_srcs = [ "oauth2/refreshing_credentials_wrapper.cc", "oauth2/service_account_credentials.cc", "object_access_control.cc", + "object_contexts.cc", "object_metadata.cc", "object_read_stream.cc", "object_retention.cc", diff --git a/google/cloud/storage/google_cloud_cpp_storage.cmake b/google/cloud/storage/google_cloud_cpp_storage.cmake index 6867722eadaf7..cc6ecd4c2cf35 100644 --- a/google/cloud/storage/google_cloud_cpp_storage.cmake +++ b/google/cloud/storage/google_cloud_cpp_storage.cmake @@ -229,6 +229,8 @@ add_library( oauth2/service_account_credentials.h object_access_control.cc object_access_control.h + object_contexts.cc + object_contexts.h object_metadata.cc object_metadata.h object_read_stream.cc diff --git a/google/cloud/storage/internal/grpc/object_request_parser.cc b/google/cloud/storage/internal/grpc/object_request_parser.cc index 81a562c4e78c9..d520c78b62a6f 100644 --- a/google/cloud/storage/internal/grpc/object_request_parser.cc +++ b/google/cloud/storage/internal/grpc/object_request_parser.cc @@ -155,6 +155,17 @@ Status SetObjectMetadata(google::storage::v2::Object& resource, *resource.mutable_custom_time() = google::cloud::internal::ToProtoTimestamp(metadata.custom_time()); } + if (metadata.has_contexts()) { + auto& contexts_proto = *resource.mutable_contexts(); + if (metadata.contexts().has_custom()) { + auto& custom_map = *contexts_proto.mutable_custom(); + for (auto const& kv : metadata.contexts().custom()) { + if (kv.second.has_value()) { + custom_map[kv.first].set_value(kv.second->value); + } + } + } + } return Status{}; } @@ -288,6 +299,17 @@ StatusOr ToProto( *destination.mutable_custom_time() = google::cloud::internal::ToProtoTimestamp(metadata.custom_time()); } + if (metadata.has_contexts()) { + auto& contexts_proto = *destination.mutable_contexts(); + if (metadata.contexts().has_custom()) { + auto& custom_map = *contexts_proto.mutable_custom(); + for (auto const& kv : metadata.contexts().custom()) { + if (kv.second.has_value()) { + custom_map[kv.first].set_value(kv.second->value); + } + } + } + } } for (auto const& s : request.source_objects()) { google::storage::v2::ComposeObjectRequest::SourceObject source; @@ -516,6 +538,23 @@ StatusOr ToProto( request.metadata().custom_time()); } + if (request.metadata().has_contexts()) { + result.mutable_update_mask()->add_paths("contexts"); + auto& contexts_proto = *object.mutable_contexts(); + if (request.metadata().contexts().has_custom()) { + auto& custom_map = *contexts_proto.mutable_custom(); + for (auto const& kv : request.metadata().contexts().custom()) { + if (kv.second.has_value()) { + custom_map[kv.first].set_value(kv.second->value); + } else { + custom_map.erase(kv.first); + } + } + } else { + contexts_proto.clear_custom(); + } + } + // We need to check each modifiable field. result.mutable_update_mask()->add_paths("cache_control"); object.set_cache_control(request.metadata().cache_control()); diff --git a/google/cloud/storage/internal/object_metadata_parser.cc b/google/cloud/storage/internal/object_metadata_parser.cc index 4f5e7efa20795..71066daae9a5f 100644 --- a/google/cloud/storage/internal/object_metadata_parser.cc +++ b/google/cloud/storage/internal/object_metadata_parser.cc @@ -44,6 +44,35 @@ void SetIfNotEmpty(nlohmann::json& json, char const* key, json[key] = value; } +/** + * Populates the "contexts" field in the JSON object from the given metadata. + */ +void SetJsonContextsIfNotEmpty(nlohmann::json& json, + ObjectMetadata const& meta) { + if (!meta.has_contexts()) { + return; + } + if (meta.contexts().has_custom()) { + nlohmann::json custom_json; + for (auto const& kv : meta.contexts().custom()) { + if (kv.second.has_value()) { + nlohmann::json item; + item["value"] = kv.second.value().value; + item["createTime"] = google::cloud::internal::FormatRfc3339( + kv.second.value().create_time); + item["updateTime"] = google::cloud::internal::FormatRfc3339( + kv.second.value().update_time); + custom_json[kv.first] = std::move(item); + } else { + custom_json[kv.first] = nullptr; + } + } + json["contexts"] = nlohmann::json{{"custom", std::move(custom_json)}}; + } else { + json["contexts"] = nlohmann::json{{"custom", nullptr}}; + } +} + Status ParseAcl(ObjectMetadata& meta, nlohmann::json const& json) { auto i = json.find("acl"); if (i == json.end()) return Status{}; @@ -160,6 +189,40 @@ Status ParseRetention(ObjectMetadata& meta, nlohmann::json const& json) { return Status{}; } +Status ParseContexts(ObjectMetadata& meta, nlohmann::json const& json) { + auto f_contexts = json.find("contexts"); + if (f_contexts == json.end()) return Status{}; + + auto f_custom = f_contexts->find("custom"); + if (f_custom == f_contexts->end()) return Status{}; + + ObjectContexts contexts; + for (auto const& kv : f_custom->items()) { + if (kv.value().is_null()) { + contexts.upsert_custom_context(kv.key(), absl::nullopt); + + } else { + ObjectCustomContextPayload payload; + auto value = kv.value().value("value", ""); + payload.value = value; + + auto create_time = + internal::ParseTimestampField(kv.value(), "createTime"); + if (!create_time) return std::move(create_time).status(); + payload.create_time = *create_time; + + auto update_time = + internal::ParseTimestampField(kv.value(), "updateTime"); + if (!update_time) return std::move(update_time).status(); + payload.update_time = *update_time; + + contexts.upsert_custom_context(kv.key(), std::move(payload)); + } + } + meta.set_contexts(std::move(contexts)); + return Status{}; +} + Status ParseSize(ObjectMetadata& meta, nlohmann::json const& json) { auto v = internal::ParseUnsignedLongField(json, "size"); if (!v) return std::move(v).status(); @@ -296,6 +359,7 @@ StatusOr ObjectMetadataParser::FromJson( ParseOwner, ParseRetentionExpirationTime, ParseRetention, + ParseContexts, [](ObjectMetadata& meta, nlohmann::json const& json) { return SetStringField(meta, json, "selfLink", &ObjectMetadata::set_self_link); @@ -372,6 +436,8 @@ nlohmann::json ObjectMetadataJsonForCompose(ObjectMetadata const& meta) { meta.retention().retain_until_time)}}; } + SetJsonContextsIfNotEmpty(metadata_as_json, meta); + return metadata_as_json; } @@ -430,6 +496,8 @@ nlohmann::json ObjectMetadataJsonForUpdate(ObjectMetadata const& meta) { meta.retention().retain_until_time)}}; } + SetJsonContextsIfNotEmpty(metadata_as_json, meta); + return metadata_as_json; } diff --git a/google/cloud/storage/object_contexts.cc b/google/cloud/storage/object_contexts.cc new file mode 100644 index 0000000000000..181495442569a --- /dev/null +++ b/google/cloud/storage/object_contexts.cc @@ -0,0 +1,55 @@ +// Copyright 2026 Google LLC +// +// Licensed 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 +// +// https://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 "google/cloud/storage/object_contexts.h" +#include "google/cloud/internal/format_time_point.h" +#include + +namespace google { +namespace cloud { +namespace storage { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN + +std::ostream& operator<<(std::ostream& os, + ObjectCustomContextPayload const& rhs) { + return os << "ObjectCustomContextPayload={value=" << rhs.value + << ", create_time=" + << google::cloud::internal::FormatRfc3339(rhs.create_time) + << ", update_time=" + << google::cloud::internal::FormatRfc3339(rhs.update_time) << "}"; +} + +std::ostream& operator<<(std::ostream& os, ObjectContexts const& rhs) { + os << "ObjectContexts={custom={"; + if (rhs.has_custom()) { + char const* sep = ""; + for (auto const& kv : rhs.custom()) { + os << sep << kv.first << "="; + if (kv.second.has_value()) { + os << kv.second.value(); + } else { + os << "null"; + } + sep = ",\n"; + } + } else { + os << "null"; + } + return os << "}}"; +} + +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END +} // namespace storage +} // namespace cloud +} // namespace google diff --git a/google/cloud/storage/object_contexts.h b/google/cloud/storage/object_contexts.h new file mode 100644 index 0000000000000..77cd7da03642f --- /dev/null +++ b/google/cloud/storage/object_contexts.h @@ -0,0 +1,126 @@ +// Copyright 2026 Google LLC +// +// Licensed 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 +// +// https://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. + +#ifndef GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_STORAGE_OBJECT_CONTEXTS_H +#define GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_STORAGE_OBJECT_CONTEXTS_H + +#include "google/cloud/storage/version.h" +#include "absl/types/optional.h" +#include +#include +#include + +namespace google { +namespace cloud { +namespace storage { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN + +/** + * Represents the payload of a user-defined object context. + */ +struct ObjectCustomContextPayload { + std::string value; + + std::chrono::system_clock::time_point create_time; + + std::chrono::system_clock::time_point update_time; +}; + +inline bool operator==(ObjectCustomContextPayload const& lhs, + ObjectCustomContextPayload const& rhs) { + return std::tie(lhs.value, lhs.create_time, lhs.update_time) == + std::tie(rhs.value, rhs.create_time, rhs.update_time); +}; + +inline bool operator!=(ObjectCustomContextPayload const& lhs, + ObjectCustomContextPayload const& rhs) { + return !(lhs == rhs); +} + +std::ostream& operator<<(std::ostream& os, + ObjectCustomContextPayload const& rhs); + +/** + * Specifies the custom contexts of an object. + */ +struct ObjectContexts { + public: + // Returns true if the map itself exists. + bool has_custom() const { return custom_map_.has_value(); } + + /** + * Returns true if the map exists AND the key is present AND the value is + * a valid value. + */ + bool has_custom(std::string const& key) const { + if (!has_custom()) { + return false; + } + return custom_map_->find(key) != custom_map_->end() && + custom_map_->at(key).has_value(); + } + + /** + * The `custom` attribute of the object contexts. + * Values are now absl::optional. + * + * It is undefined behavior to call this member function if + * `has_custom() == false`. + */ + std::map> const& + custom() const { + return *custom_map_; + } + + /** + * Upserts a context. Passing absl::nullopt for the value + * represents a "null" entry in the map. + */ + void upsert_custom_context(std::string const& key, + absl::optional value) { + if (!has_custom()) { + custom_map_.emplace(); + } + + (*custom_map_)[key] = std::move(value); + } + + void reset_custom() { custom_map_.reset(); } + + bool operator==(ObjectContexts const& other) const { + return custom_map_ == other.custom_map_; + } + + bool operator!=(ObjectContexts const& other) const { + return !(*this == other); + } + + private: + /** + * Represents the map of user-defined object contexts. + * Inner optional allows keys to point to a "null" value. + */ + absl::optional< + std::map>> + custom_map_; +}; + +std::ostream& operator<<(std::ostream& os, ObjectContexts const& rhs); + +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END +} // namespace storage +} // namespace cloud +} // namespace google + +#endif // GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_STORAGE_OBJECT_CONTEXTS_H diff --git a/google/cloud/storage/object_metadata.cc b/google/cloud/storage/object_metadata.cc index 08af74427b10b..8d91040ddbfba 100644 --- a/google/cloud/storage/object_metadata.cc +++ b/google/cloud/storage/object_metadata.cc @@ -73,6 +73,7 @@ bool operator==(ObjectMetadata const& lhs, ObjectMetadata const& rhs) { && lhs.updated_ == rhs.updated_ // && lhs.soft_delete_time_ == rhs.soft_delete_time_ // && lhs.hard_delete_time_ == rhs.hard_delete_time_ // + && lhs.contexts_ == rhs.contexts_ // ; } @@ -133,6 +134,9 @@ std::ostream& operator<<(std::ostream& os, ObjectMetadata const& rhs) { if (rhs.has_hard_delete_time()) { os << ", hard_delete_time=" << FormatRfc3339(rhs.hard_delete_time()); } + if (rhs.has_contexts()) { + os << ", contexts=" << rhs.contexts(); + } return os << "}"; } diff --git a/google/cloud/storage/object_metadata.h b/google/cloud/storage/object_metadata.h index bc40bb6706899..c3ad0621ffee7 100644 --- a/google/cloud/storage/object_metadata.h +++ b/google/cloud/storage/object_metadata.h @@ -17,6 +17,7 @@ #include "google/cloud/storage/internal/complex_option.h" #include "google/cloud/storage/object_access_control.h" +#include "google/cloud/storage/object_contexts.h" #include "google/cloud/storage/object_retention.h" #include "google/cloud/storage/owner.h" #include "google/cloud/storage/version.h" @@ -450,6 +451,29 @@ class ObjectMetadata { return *this; } + /// Returns `true` if the object has custom contexts. + bool has_contexts() const { return contexts_.has_value(); } + + /** + * The object's user custom contexts. + * + * It is undefined behavior to call this member function if + * `has_contexts() == false`. + */ + ObjectContexts const& contexts() const { return *contexts_; } + + /// Change or set the object's custom contexts. + ObjectMetadata& set_contexts(ObjectContexts v) { + contexts_ = std::move(v); + return *this; + } + + /// Reset the object contexts. + ObjectMetadata& reset_contexts() { + contexts_.reset(); + return *this; + } + /// An HTTPS link to the object metadata. std::string const& self_link() const { return self_link_; } @@ -612,6 +636,7 @@ class ObjectMetadata { std::string md5_hash_; std::string media_link_; std::map metadata_; + absl::optional contexts_; std::string name_; absl::optional owner_; std::chrono::system_clock::time_point retention_expiration_time_; diff --git a/google/cloud/storage/object_metadata_test.cc b/google/cloud/storage/object_metadata_test.cc index 1cc291cb4cc90..8fd26eb9214b4 100644 --- a/google/cloud/storage/object_metadata_test.cc +++ b/google/cloud/storage/object_metadata_test.cc @@ -120,6 +120,15 @@ ObjectMetadata CreateObjectMetadataForTest() { "mode": "Unlocked", "retainUntilTime": "2024-07-18T00:00:00Z" }, + "contexts": { + "custom": { + "environment": { + "value": "prod", + "createTime": "2024-07-18T00:00:00Z", + "updateTime": "2024-07-18T00:00:00Z" + } + } + }, "selfLink": "https://storage.googleapis.com/storage/v1/b/foo-bar/o/baz", "size": 102400, "storageClass": "STANDARD", @@ -207,6 +216,14 @@ TEST(ObjectMetadataTest, Parse) { EXPECT_EQ(actual.hard_delete_time(), std::chrono::system_clock::from_time_t(1710160496L) + std::chrono::milliseconds(789)); + ASSERT_TRUE(actual.has_contexts() && actual.contexts().has_custom()); + EXPECT_EQ( + actual.contexts().custom().at("environment"), + (ObjectCustomContextPayload{ + "prod", + google::cloud::internal::ParseRfc3339("2024-07-18T00:00:00Z").value(), + google::cloud::internal::ParseRfc3339("2024-07-18T00:00:00Z") + .value()})); } /// @test Verify that the IOStream operator works as expected. @@ -267,6 +284,11 @@ TEST(ObjectMetadataTest, JsonForCompose) { {"customTime", "2020-08-10T12:34:56Z"}, {"retention", {{"mode", "Unlocked"}, {"retainUntilTime", "2024-07-18T00:00:00Z"}}}, + {"contexts", + {{"custom", nlohmann::json{{"environment", + {{"createTime", "2024-07-18T00:00:00Z"}, + {"updateTime", "2024-07-18T00:00:00Z"}, + {"value", "prod"}}}}}}}, }; EXPECT_EQ(expected, actual) << "diff=" << nlohmann::json::diff(expected, actual); @@ -306,7 +328,13 @@ TEST(ObjectMetadataTest, JsonForCopy) { {"customTime", "2020-08-10T12:34:56Z"}, {"retention", {{"mode", "Unlocked"}, {"retainUntilTime", "2024-07-18T00:00:00Z"}}}, + {"contexts", + {{"custom", nlohmann::json{{"environment", + {{"createTime", "2024-07-18T00:00:00Z"}, + {"updateTime", "2024-07-18T00:00:00Z"}, + {"value", "prod"}}}}}}}, }; + EXPECT_EQ(expected, actual) << "diff=" << nlohmann::json::diff(expected, actual); } @@ -348,6 +376,11 @@ TEST(ObjectMetadataTest, JsonForInsert) { {"customTime", "2020-08-10T12:34:56Z"}, {"retention", {{"mode", "Unlocked"}, {"retainUntilTime", "2024-07-18T00:00:00Z"}}}, + {"contexts", + {{"custom", nlohmann::json{{"environment", + {{"createTime", "2024-07-18T00:00:00Z"}, + {"updateTime", "2024-07-18T00:00:00Z"}, + {"value", "prod"}}}}}}}, }; EXPECT_EQ(expected, actual) << "diff=" << nlohmann::json::diff(expected, actual); @@ -388,6 +421,11 @@ TEST(ObjectMetadataTest, JsonForRewrite) { {"customTime", "2020-08-10T12:34:56Z"}, {"retention", {{"mode", "Unlocked"}, {"retainUntilTime", "2024-07-18T00:00:00Z"}}}, + {"contexts", + {{"custom", nlohmann::json{{"environment", + {{"createTime", "2024-07-18T00:00:00Z"}, + {"updateTime", "2024-07-18T00:00:00Z"}, + {"value", "prod"}}}}}}}, }; EXPECT_EQ(expected, actual) << "diff=" << nlohmann::json::diff(expected, actual); @@ -429,6 +467,11 @@ TEST(ObjectMetadataTest, JsonForUpdate) { {"customTime", "2020-08-10T12:34:56Z"}, {"retention", {{"mode", "Unlocked"}, {"retainUntilTime", "2024-07-18T00:00:00Z"}}}, + {"contexts", + {{"custom", nlohmann::json{{"environment", + {{"createTime", "2024-07-18T00:00:00Z"}, + {"updateTime", "2024-07-18T00:00:00Z"}, + {"value", "prod"}}}}}}}, }; EXPECT_EQ(expected, actual) << "diff=" << nlohmann::json::diff(expected, actual); @@ -645,6 +688,30 @@ TEST(ObjectMetadataTest, ResetRetention) { EXPECT_NE(expected, copy); } +/// @test Verify we can change the `contexts` field. +TEST(ObjectMetadataTest, SetContexts) { + auto const expected = CreateObjectMetadataForTest(); + auto copy = expected; + auto const context_payload = + ObjectCustomContextPayload{"engineering", {}, {}}; + ObjectContexts contexts; + contexts.upsert_custom_context("department", context_payload); + copy.set_contexts(contexts); + EXPECT_TRUE(expected.contexts().has_custom()); + EXPECT_TRUE(copy.contexts().has_custom()); + EXPECT_EQ(contexts, copy.contexts()); + EXPECT_NE(expected, copy); +} + +/// @test Verify we can reset the `contexts` field. +TEST(ObjectMetadataTest, DeleteContexts) { + auto const expected = CreateObjectMetadataForTest(); + auto copy = expected; + copy.reset_contexts(); + EXPECT_FALSE(copy.has_contexts()); + EXPECT_NE(expected, copy); +} + TEST(ObjectMetadataPatchBuilder, SetAcl) { ObjectMetadataPatchBuilder builder; builder.SetAcl({internal::ObjectAccessControlParser::FromString( diff --git a/google/cloud/storage/tests/object_basic_crud_integration_test.cc b/google/cloud/storage/tests/object_basic_crud_integration_test.cc index a1c6e454f47c3..50bff5eb53477 100644 --- a/google/cloud/storage/tests/object_basic_crud_integration_test.cc +++ b/google/cloud/storage/tests/object_basic_crud_integration_test.cc @@ -31,6 +31,13 @@ #include #include +namespace { +// Helper function to check if a time point is set (i.e. not the default value). +bool IsSet(std::chrono::system_clock::time_point tp) { + return tp != std::chrono::system_clock::time_point{}; +} +} // namespace + namespace google { namespace cloud { namespace storage { @@ -60,6 +67,35 @@ struct ObjectBasicCRUDIntegrationTest } return MakeIntegrationTestClient(std::move(options)); } + + void SetUp() override { + // 1. Run the base class SetUp first. This initializes 'bucket_name_' + // from the environment variable. + ::google::cloud::storage::testing::ObjectIntegrationTest::SetUp(); + + // 2. Create a client to interact with the emulator/backend. + auto client = MakeIntegrationTestClient(); + + // 3. Check if the bucket exists. + auto metadata = client.GetBucketMetadata(bucket_name_); + + // 4. If it's missing (kNotFound), create it. + if (metadata.status().code() == StatusCode::kNotFound) { + // Use a default project ID if the env var isn't set (common in local + // emulators). + auto project_id = google::cloud::internal::GetEnv("GOOGLE_CLOUD_PROJECT") + .value_or("test-project"); + + auto created = client.CreateBucketForProject(bucket_name_, project_id, + BucketMetadata()); + ASSERT_STATUS_OK(created) + << "Failed to auto-create missing bucket: " << bucket_name_; + } else { + // If it exists (or failed for another reason), assert it is OK. + ASSERT_STATUS_OK(metadata) + << "Failed to verify bucket existence: " << bucket_name_; + } + } }; /// @test Verify the Object CRUD (Create, Get, Update, Delete, List) operations. @@ -93,6 +129,7 @@ TEST_F(ObjectBasicCRUDIntegrationTest, BasicCRUD) { Projection("full")); ASSERT_STATUS_OK(get_meta); EXPECT_EQ(*get_meta, *insert_meta); + EXPECT_FALSE(insert_meta->has_contexts()) << *insert_meta; ObjectMetadata update = *get_meta; update.mutable_acl().emplace_back( @@ -156,6 +193,127 @@ TEST_F(ObjectBasicCRUDIntegrationTest, BasicCRUD) { EXPECT_THAT(list_object_names(), Not(Contains(object_name))); } +/// @test Verify the Object CRUD operations with object contexts. +TEST_F(ObjectBasicCRUDIntegrationTest, BasicCRUDWithObjectContexts) { + auto client = MakeIntegrationTestClient(); + + auto list_object_names = [&client, this] { + std::vector names; + for (auto o : client.ListObjects(bucket_name_)) { + EXPECT_STATUS_OK(o); + if (!o) break; + names.push_back(o->name()); + } + return names; + }; + + auto object_name = MakeRandomObjectName(); + ASSERT_THAT(list_object_names(), Not(Contains(object_name))) + << "Test aborted. The object <" << object_name << "> already exists." + << "This is unexpected as the test generates a random object name."; + + // Create the object, but only if it does not exist already, inserting + // a custom context {"department": "engineering"}. + ObjectContexts contexts; + ObjectCustomContextPayload payload; + payload.value = "engineering"; + contexts.upsert_custom_context("department", std::move(payload)); + StatusOr insert_meta = client.InsertObject( + bucket_name_, object_name, LoremIpsum(), IfGenerationMatch(0), + Projection("full"), + WithObjectMetadata(ObjectMetadata().set_contexts(contexts))); + ASSERT_STATUS_OK(insert_meta); + EXPECT_THAT(list_object_names(), Contains(object_name).Times(1)); + + // Verify the response ObjectMetadata has the custom contexts we set. + StatusOr get_meta = client.GetObjectMetadata( + bucket_name_, object_name, Generation(insert_meta->generation()), + Projection("full")); + ASSERT_STATUS_OK(get_meta); + EXPECT_TRUE(get_meta->has_contexts()) << *get_meta; + EXPECT_TRUE(get_meta->contexts().has_custom("department")) << *get_meta; + EXPECT_EQ("engineering", + get_meta->contexts().custom().at("department")->value) + << *get_meta; + EXPECT_TRUE( + IsSet(get_meta->contexts().custom().at("department")->update_time)) + << *get_meta; + EXPECT_TRUE( + IsSet(get_meta->contexts().custom().at("department")->create_time)) + << *get_meta; + + // Update object with a new value "engineering and research" for the existing + // custom context, and also add another custom context {"region": + // "Asia Pacific"}. + ObjectMetadata update = *get_meta; + ObjectContexts contexts_updated; + ObjectCustomContextPayload payload_updated; + payload_updated.value = "engineering and research"; + contexts_updated.upsert_custom_context("department", + std::move(payload_updated)); + ObjectCustomContextPayload payload_another; + payload_another.value = "Asia Pacific"; + contexts_updated.upsert_custom_context("region", std::move(payload_another)); + update.set_contexts(contexts_updated); + StatusOr updated_meta = client.UpdateObject( + bucket_name_, object_name, update, Projection("full")); + ASSERT_STATUS_OK(updated_meta); + + // Verify the response ObjectMetadata has the updated custom contexts. + EXPECT_TRUE(updated_meta->has_contexts()) << *updated_meta; + EXPECT_TRUE(updated_meta->contexts().has_custom("department")) + << *updated_meta; + // EXPECT_EQ("engineering and research", + // updated_meta->contexts().custom().at("department")->value) + // << *updated_meta; + EXPECT_TRUE(updated_meta->contexts().has_custom("region")) << *updated_meta; + // EXPECT_EQ("Asia Pacific", + // updated_meta->contexts().custom().at("region")->value) + // << *updated_meta; + EXPECT_TRUE( + IsSet(updated_meta->contexts().custom().at("region")->update_time)) + << *updated_meta; + EXPECT_TRUE( + IsSet(updated_meta->contexts().custom().at("region")->create_time)) + << *updated_meta; + + // Update object with deletion of the "department" custom context. + ObjectContexts contexts_deleted; + contexts_deleted.upsert_custom_context("department", absl::nullopt); + update.set_contexts(contexts_deleted); + StatusOr deleted_meta = client.UpdateObject( + bucket_name_, object_name, update, Projection("full")); + ASSERT_STATUS_OK(deleted_meta); + + // Verify the response ObjectMetadata has the "department" key is set to null, + // but the "region" key still present with value. + EXPECT_TRUE(deleted_meta->has_contexts()) << *deleted_meta; + EXPECT_FALSE(deleted_meta->contexts().has_custom("department")) + << *deleted_meta; + EXPECT_TRUE(deleted_meta->contexts().has_custom("region")) << *deleted_meta; + // EXPECT_TRUE(deleted_meta->contexts().custom().at("region").has_value()) + // << *deleted_meta; + // EXPECT_EQ("Asia Pacific", + // deleted_meta->contexts().custom().at("region")->value) + // << *deleted_meta; + + // Update object with reset of the custom field. + ObjectContexts contexts_reset; + update.set_contexts(contexts_reset); + StatusOr reset_meta = client.UpdateObject( + bucket_name_, object_name, update, Projection("full")); + ASSERT_STATUS_OK(reset_meta); + + // Verify the response ObjectMetadata that no custom contexts exists. This + // is the default behavior as if the custom field is never set. + EXPECT_FALSE(reset_meta->has_contexts()) << *reset_meta; + + // This is the test for Object CRUD, we cannot rely on `ScheduleForDelete()`. + auto status = client.DeleteObject(bucket_name_, object_name); + ASSERT_STATUS_OK(status); + EXPECT_THAT(list_object_names(), Not(Contains(object_name))); +} + /// @test Verify that the client works with non-default endpoints. TEST_F(ObjectBasicCRUDIntegrationTest, NonDefaultEndpointInsert) { auto client = MakeNonDefaultClient();