diff --git a/ci/cloudbuild/builds/lib/integration.sh b/ci/cloudbuild/builds/lib/integration.sh index b733dc71fca40..f97bb655eddb0 100644 --- a/ci/cloudbuild/builds/lib/integration.sh +++ b/ci/cloudbuild/builds/lib/integration.sh @@ -31,7 +31,7 @@ source module ci/lib/io.sh export PATH="${HOME}/.local/bin:${PATH}" python3 -m pip uninstall -y --quiet googleapis-storage-testbench python3 -m pip install --upgrade --user --quiet --disable-pip-version-check \ - "git+https://github.com/googleapis/storage-testbench@v0.60.0" + "git+https://github.com/googleapis/storage-testbench@v0.61.0" # Some of the tests will need a valid roots.pem file. rm -f /dev/shm/roots.pem diff --git a/google/cloud/storage/google_cloud_cpp_storage.bzl b/google/cloud/storage/google_cloud_cpp_storage.bzl index 7f244903705ab..ed40ddb04ab5f 100644 --- a/google/cloud/storage/google_cloud_cpp_storage.bzl +++ b/google/cloud/storage/google_cloud_cpp_storage.bzl @@ -116,6 +116,7 @@ google_cloud_cpp_storage_hdrs = [ "notification_metadata.h", "notification_payload_format.h", "object_access_control.h", + "object_contexts.h", "object_metadata.h", "object_read_stream.h", "object_retention.h", @@ -219,6 +220,7 @@ google_cloud_cpp_storage_srcs = [ "list_objects_reader.cc", "notification_metadata.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 f24107bca2cd9..69a7482c71847 100644 --- a/google/cloud/storage/google_cloud_cpp_storage.cmake +++ b/google/cloud/storage/google_cloud_cpp_storage.cmake @@ -197,6 +197,8 @@ add_library( notification_payload_format.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 @@ -473,6 +475,7 @@ if (BUILD_TESTING) list_objects_reader_test.cc notification_metadata_test.cc object_access_control_test.cc + object_contexts_test.cc object_metadata_test.cc object_retention_test.cc object_stream_test.cc diff --git a/google/cloud/storage/internal/grpc/object_metadata_parser.cc b/google/cloud/storage/internal/grpc/object_metadata_parser.cc index 230cbfdcbde8b..b22bc21dd11ed 100644 --- a/google/cloud/storage/internal/grpc/object_metadata_parser.cc +++ b/google/cloud/storage/internal/grpc/object_metadata_parser.cc @@ -171,6 +171,19 @@ storage::ObjectMetadata FromProto(google::storage::v2::Object object, metadata.set_hard_delete_time( google::cloud::internal::ToChronoTimePoint(object.hard_delete_time())); } + if (object.has_contexts()) { + storage::ObjectContexts contexts; + for (auto const& kv : object.contexts().custom()) { + storage::ObjectCustomContextPayload payload; + payload.value = kv.second.value(); + payload.create_time = + google::cloud::internal::ToChronoTimePoint(kv.second.create_time()); + payload.update_time = + google::cloud::internal::ToChronoTimePoint(kv.second.update_time()); + contexts.upsert(kv.first, std::move(payload)); + } + metadata.set_contexts(std::move(contexts)); + } return metadata; } diff --git a/google/cloud/storage/internal/grpc/object_metadata_parser_test.cc b/google/cloud/storage/internal/grpc/object_metadata_parser_test.cc index a497a5b0ef7cc..b52f28122e4eb 100644 --- a/google/cloud/storage/internal/grpc/object_metadata_parser_test.cc +++ b/google/cloud/storage/internal/grpc/object_metadata_parser_test.cc @@ -79,6 +79,32 @@ TEST(GrpcClientFromProto, ObjectSimple) { } metadata: { key: "test-key-1" value: "test-value-1" } metadata: { key: "test-key-2" value: "test-value-2" } + contexts: { + custom: { + key: "custom-key-1" value: { + value: "custom-value-1" + create_time: { + seconds: 1565194924 + nanos: 456789012 + } + update_time: { + seconds: 1565194924 + nanos: 456789012 + } + }} + custom: { + key: "custom-key-2" value: { + value: "custom-value-2" + create_time: { + seconds: 1565194924 + nanos: 456789012 + } + update_time: { + seconds: 1709555696 + nanos: 987654321 + } + }} + } event_based_hold: true name: "test-object-name" bucket: "test-bucket" @@ -141,6 +167,20 @@ TEST(GrpcClientFromProto, ObjectSimple) { "test-key-1": "test-value-1", "test-key-2": "test-value-2" }, + "contexts": { + "custom": { + "custom-key-1": { + "value": "custom-value-1", + "createTime": "2019-08-07T16:22:04.456789012Z", + "updateTime": "2019-08-07T16:22:04.456789012Z" + }, + "custom-key-2": { + "value": "custom-value-2", + "createTime": "2019-08-07T16:22:04.456789012Z", + "updateTime": "2024-03-04T12:34:56.987654321Z" + } + } + }, "eventBasedHold": true, "name": "test-object-name", "id": "test-bucket/test-object-name/2345", diff --git a/google/cloud/storage/internal/grpc/object_request_parser.cc b/google/cloud/storage/internal/grpc/object_request_parser.cc index 934dded5ce5b5..1b12bf54829ad 100644 --- a/google/cloud/storage/internal/grpc/object_request_parser.cc +++ b/google/cloud/storage/internal/grpc/object_request_parser.cc @@ -23,6 +23,7 @@ #include "google/cloud/internal/make_status.h" #include "google/cloud/internal/time_utils.h" #include "absl/strings/str_cat.h" +#include #include namespace google { @@ -155,6 +156,15 @@ Status SetObjectMetadata(google::storage::v2::Object& resource, *resource.mutable_custom_time() = google::cloud::internal::ToProtoTimestamp(metadata.custom_time()); } + if (metadata.has_contexts()) { + auto& custom_map = *resource.mutable_contexts()->mutable_custom(); + for (auto const& kv : metadata.contexts().custom()) { + // In request, the create_time and update_time are ignored by the server, + // hence there is no need to parse them. + custom_map[kv.first].set_value(kv.second.value); + } + } + return Status{}; } @@ -257,6 +267,49 @@ Status FinalizeChecksums(google::storage::v2::ObjectChecksums& checksums, return {}; } +void PatchGrpcMetadata( + google::storage::v2::UpdateObjectRequest& result, + google::storage::v2::Object& object, + storage::ObjectMetadataPatchBuilder const& patch_builder) { + auto const& subpatch = + storage::internal::PatchBuilderDetails::GetMetadataSubPatch( + patch_builder); + if (subpatch.is_null()) { + object.clear_metadata(); + result.mutable_update_mask()->add_paths("metadata"); + } else { + for (auto const& kv : subpatch.items()) { + result.mutable_update_mask()->add_paths("metadata." + kv.key()); + auto const& v = kv.value(); + if (!v.is_string()) continue; + (*object.mutable_metadata())[kv.key()] = v.get(); + } + } +} + +void PatchGrpcContexts( + google::storage::v2::UpdateObjectRequest& result, + google::storage::v2::Object& object, + storage::ObjectMetadataPatchBuilder const& patch_builder) { + auto const& contexts_subpatch = + storage::internal::PatchBuilderDetails::GetCustomContextsSubPatch( + patch_builder); + if (contexts_subpatch.is_null()) { + object.clear_contexts(); + result.mutable_update_mask()->add_paths("contexts.custom"); + } else { + for (auto const& kv : contexts_subpatch.items()) { + result.mutable_update_mask()->add_paths("contexts.custom." + kv.key()); + auto const& v = kv.value(); + if (v.is_object() && v.contains("value")) { + auto& payload = + (*object.mutable_contexts()->mutable_custom())[kv.key()]; + payload.set_value(v["value"].get()); + } + } + } +} + } // namespace StatusOr ToProto( @@ -276,6 +329,13 @@ StatusOr ToProto( for (auto const& kv : metadata.metadata()) { (*destination.mutable_metadata())[kv.first] = kv.second; } + if (metadata.has_contexts()) { + for (auto const& kv : metadata.contexts().custom()) { + auto& payload = + (*destination.mutable_contexts()->mutable_custom())[kv.first]; + payload.set_value(kv.second.value); + } + } destination.set_content_encoding(metadata.content_encoding()); destination.set_content_disposition(metadata.content_disposition()); destination.set_cache_control(metadata.cache_control()); @@ -441,20 +501,8 @@ StatusOr ToProto( result.mutable_update_mask()->add_paths(field.grpc_name); } - auto const& subpatch = - storage::internal::PatchBuilderDetails::GetMetadataSubPatch( - request.patch()); - if (subpatch.is_null()) { - object.clear_metadata(); - result.mutable_update_mask()->add_paths("metadata"); - } else { - for (auto const& kv : subpatch.items()) { - result.mutable_update_mask()->add_paths("metadata." + kv.key()); - auto const& v = kv.value(); - if (!v.is_string()) continue; - (*object.mutable_metadata())[kv.key()] = v.get(); - } - } + PatchGrpcMetadata(result, object, request.patch()); + PatchGrpcContexts(result, object, request.patch()); // We need to check each modifiable field. struct StringField { @@ -510,6 +558,19 @@ StatusOr ToProto( (*object.mutable_metadata())[kv.first] = kv.second; } + if (request.metadata().has_contexts()) { + result.mutable_update_mask()->add_paths("contexts"); + auto& custom_map = *object.mutable_contexts()->mutable_custom(); + + for (auto const& kv : request.metadata().contexts().custom()) { + google::storage::v2::ObjectCustomContextPayload& payload_ref = + custom_map[kv.first]; + // In request, the create_time and update_time are ignored by + // the server, hence there is no need to parse them. + payload_ref.set_value(kv.second.value); + } + } + if (request.metadata().has_custom_time()) { result.mutable_update_mask()->add_paths("custom_time"); *object.mutable_custom_time() = google::cloud::internal::ToProtoTimestamp( diff --git a/google/cloud/storage/internal/grpc/object_request_parser_test.cc b/google/cloud/storage/internal/grpc/object_request_parser_test.cc index 8b91d9b8018f9..a429dd70f2718 100644 --- a/google/cloud/storage/internal/grpc/object_request_parser_test.cc +++ b/google/cloud/storage/internal/grpc/object_request_parser_test.cc @@ -88,6 +88,12 @@ google::storage::v2::Object ExpectedFullObjectMetadata() { temporary_hold: true metadata: { key: "test-metadata-key1" value: "test-value1" } metadata: { key: "test-metadata-key2" value: "test-value2" } + contexts: { + custom: { + key: "custom-key-1" + value: { value: "custom-value-1" } + } + } event_based_hold: true custom_time { seconds: 1643126687 nanos: 123000000 } )pb"; @@ -113,6 +119,8 @@ storage::ObjectMetadata FullObjectMetadata() { .set_temporary_hold(true) .upsert_metadata("test-metadata-key1", "test-value1") .upsert_metadata("test-metadata-key2", "test-value2") + .set_contexts(storage::ObjectContexts().upsert( + "custom-key-1", {"custom-value-1", {}, {}})) .set_event_based_hold(true) .set_custom_time(std::chrono::system_clock::time_point{} + std::chrono::seconds(1643126687) + @@ -479,6 +487,7 @@ TEST(GrpcObjectRequestParser, PatchObjectRequestAllOptions) { .SetContentType("test-content-type") .SetMetadata("test-metadata-key1", "test-value1") .SetMetadata("test-metadata-key2", "test-value2") + .SetContext("custom-key-1", "custom-value-1") .SetTemporaryHold(true) .SetAcl({ storage::ObjectAccessControl{} @@ -505,12 +514,13 @@ TEST(GrpcObjectRequestParser, PatchObjectRequestAllOptions) { ASSERT_STATUS_OK(actual); // First check the paths. We do not care about their order, so checking them // with IsProtoEqual does not work. - EXPECT_THAT(actual->update_mask().paths(), - UnorderedElementsAre( - "acl", "content_encoding", "content_disposition", - "cache_control", "content_language", "content_type", - "metadata.test-metadata-key1", "metadata.test-metadata-key2", - "temporary_hold", "event_based_hold", "custom_time")); + EXPECT_THAT( + actual->update_mask().paths(), + UnorderedElementsAre( + "acl", "content_encoding", "content_disposition", "cache_control", + "content_language", "content_type", "metadata.test-metadata-key1", + "metadata.test-metadata-key2", "temporary_hold", "event_based_hold", + "custom_time", "contexts.custom.custom-key-1")); // Clear the paths, which we already compared, and compare the proto. actual->mutable_update_mask()->clear_paths(); EXPECT_THAT(*actual, IsProtoEqual(expected)); @@ -535,6 +545,7 @@ TEST(GrpcObjectRequestParser, PatchObjectRequestAllResets) { .ResetContentType() .ResetEventBasedHold() .ResetMetadata() + .ResetContexts() .ResetTemporaryHold() .ResetCustomTime()); @@ -547,7 +558,7 @@ TEST(GrpcObjectRequestParser, PatchObjectRequestAllResets) { UnorderedElementsAre("acl", "content_encoding", "content_disposition", "cache_control", "content_language", "content_type", "metadata", "temporary_hold", "event_based_hold", - "custom_time")); + "custom_time", "contexts.custom")); // Clear the paths, which we already compared, and compare the proto. actual->mutable_update_mask()->clear_paths(); EXPECT_THAT(*actual, IsProtoEqual(expected)); @@ -604,6 +615,64 @@ TEST(GrpcObjectRequestParser, PatchObjectRequestResetMetadata) { EXPECT_THAT(*actual, IsProtoEqual(expected)); } +TEST(GrpcObjectRequestParser, PatchObjectRequestContexts) { + auto constexpr kTextProto = R"pb( + object { + bucket: "projects/_/buckets/bucket-name" + name: "object-name" + contexts: { + custom: { + key: "custom-key-1" + value: { value: "custom-value-1" } + } + } + } + update_mask {} + )pb"; + google::storage::v2::UpdateObjectRequest expected; + ASSERT_TRUE(TextFormat::ParseFromString(kTextProto, &expected)); + + storage::internal::PatchObjectRequest req( + "bucket-name", "object-name", + storage::ObjectMetadataPatchBuilder{} + .SetContext("custom-key-1", "custom-value-1") + .ResetContext("custom-key-2")); + + auto actual = ToProto(req); + ASSERT_STATUS_OK(actual); + // First check the paths. We do not care about their order, so checking them + // with IsProtoEqual does not work. + EXPECT_THAT(actual->update_mask().paths(), + UnorderedElementsAre("contexts.custom.custom-key-1", + "contexts.custom.custom-key-2")); + // Clear the paths, which we already compared, and compare the proto. + actual->mutable_update_mask()->clear_paths(); + EXPECT_THAT(*actual, IsProtoEqual(expected)); +} + +TEST(GrpcObjectRequestParser, PatchObjectRequestResetContexts) { + auto constexpr kTextProto = R"pb( + object { bucket: "projects/_/buckets/bucket-name" name: "object-name" } + update_mask {} + )pb"; + google::storage::v2::UpdateObjectRequest expected; + ASSERT_TRUE(TextFormat::ParseFromString(kTextProto, &expected)); + + storage::internal::PatchObjectRequest req( + "bucket-name", "object-name", + storage::ObjectMetadataPatchBuilder{}.ResetContexts()); + + auto actual = ToProto(req); + ASSERT_STATUS_OK(actual); + // First check the paths. We do not care about their order, so checking them + // with IsProtoEqual does not work. + EXPECT_THAT(actual->update_mask().paths(), + UnorderedElementsAre("contexts.custom")); + // Clear the paths, which we already compared, and compare the proto. + actual->mutable_update_mask()->clear_paths(); + EXPECT_THAT(*actual, IsProtoEqual(expected)); +} + TEST(GrpcObjectRequestParser, UpdateObjectRequestAllOptions) { auto constexpr kTextProto = R"pb( predefined_acl: "projectPrivate" @@ -643,7 +712,7 @@ TEST(GrpcObjectRequestParser, UpdateObjectRequestAllOptions) { UnorderedElementsAre("acl", "content_encoding", "content_disposition", "cache_control", "content_language", "content_type", "metadata", "temporary_hold", "event_based_hold", - "custom_time")); + "custom_time", "contexts")); // Clear the paths, which we already compared, and test the rest actual->mutable_update_mask()->clear_paths(); EXPECT_THAT(*actual, IsProtoEqual(expected)); diff --git a/google/cloud/storage/internal/object_metadata_parser.cc b/google/cloud/storage/internal/object_metadata_parser.cc index 4f5e7efa20795..6db33945ab3a0 100644 --- a/google/cloud/storage/internal/object_metadata_parser.cc +++ b/google/cloud/storage/internal/object_metadata_parser.cc @@ -44,6 +44,28 @@ 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; + } + + nlohmann::json custom_json; + for (auto const& kv : meta.contexts().custom()) { + nlohmann::json item; + item["value"] = kv.second.value; + item["createTime"] = + google::cloud::internal::FormatRfc3339(kv.second.create_time); + item["updateTime"] = + google::cloud::internal::FormatRfc3339(kv.second.update_time); + custom_json[kv.first] = std::move(item); + } + json["contexts"] = nlohmann::json{{"custom", std::move(custom_json)}}; +} + Status ParseAcl(ObjectMetadata& meta, nlohmann::json const& json) { auto i = json.find("acl"); if (i == json.end()) return Status{}; @@ -160,6 +182,36 @@ 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()) { + auto const& payload_json = kv.value(); + ObjectCustomContextPayload payload; + + payload.value = payload_json.value("value", ""); + + auto create_time = + internal::ParseTimestampField(payload_json, "createTime"); + if (!create_time) return std::move(create_time).status(); + payload.create_time = *create_time; + + auto update_time = + internal::ParseTimestampField(payload_json, "updateTime"); + if (!update_time) return std::move(update_time).status(); + payload.update_time = *update_time; + + contexts.upsert(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 +348,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 +425,8 @@ nlohmann::json ObjectMetadataJsonForCompose(ObjectMetadata const& meta) { meta.retention().retain_until_time)}}; } + SetJsonContextsIfNotEmpty(metadata_as_json, meta); + return metadata_as_json; } @@ -430,6 +485,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/internal/object_requests.cc b/google/cloud/storage/internal/object_requests.cc index 37857aff7bfdc..7a0e76c8144b2 100644 --- a/google/cloud/storage/internal/object_requests.cc +++ b/google/cloud/storage/internal/object_requests.cc @@ -37,6 +37,70 @@ namespace storage { GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN namespace internal { namespace { +void DiffMetadata(ObjectMetadataPatchBuilder& builder, + ObjectMetadata const& original, + ObjectMetadata const& updated) { + if (original.metadata() == updated.metadata()) return; + + if (updated.metadata().empty()) { + builder.ResetMetadata(); + return; + } + + std::map difference; + std::set_difference(original.metadata().begin(), original.metadata().end(), + updated.metadata().begin(), updated.metadata().end(), + std::inserter(difference, difference.end()), + original.metadata().value_comp()); + for (auto&& d : difference) { + builder.ResetMetadata(d.first); + } + + difference.clear(); + std::set_difference(updated.metadata().begin(), updated.metadata().end(), + original.metadata().begin(), original.metadata().end(), + std::inserter(difference, difference.end())); + for (auto&& d : difference) { + builder.SetMetadata(d.first, d.second); + } +} + +void DiffContexts(ObjectMetadataPatchBuilder& builder, + ObjectMetadata const& original, + ObjectMetadata const& updated) { + if (original.has_contexts() && updated.has_contexts()) { + if (original.contexts() != updated.contexts()) { + std::map deleted_entries; + std::set_difference( + original.contexts().custom().begin(), + original.contexts().custom().end(), + updated.contexts().custom().begin(), + updated.contexts().custom().end(), + std::inserter(deleted_entries, deleted_entries.end()), + [](auto const& a, auto const& b) { return a.first < b.first; }); + for (auto&& d : deleted_entries) { + builder.ResetContext(d.first); + } + + std::map changed_entries; + std::set_difference( + updated.contexts().custom().begin(), + updated.contexts().custom().end(), + original.contexts().custom().begin(), + original.contexts().custom().end(), + std::inserter(changed_entries, changed_entries.end())); + for (auto&& d : changed_entries) { + builder.SetContext(d.first, d.second.value); + } + } + } else if (original.has_contexts() && !updated.has_contexts()) { + builder.ResetContexts(); + } else if (!original.has_contexts() && updated.has_contexts()) { + for (auto const& c : updated.contexts().custom()) { + builder.SetContext(c.first, c.second.value); + } + } +} ObjectMetadataPatchBuilder DiffObjectMetadata(ObjectMetadata const& original, ObjectMetadata const& updated) { @@ -65,37 +129,8 @@ ObjectMetadataPatchBuilder DiffObjectMetadata(ObjectMetadata const& original, builder.SetEventBasedHold(updated.event_based_hold()); } - if (original.metadata() != updated.metadata()) { - if (updated.metadata().empty()) { - builder.ResetMetadata(); - } else { - std::map difference; - // Find the keys in the original map that are not in the new map. Using - // `std::set_difference()` works because, unlike `std::unordered_map` the - // `std::map` iterators return elements ordered by key: - std::set_difference(original.metadata().begin(), - original.metadata().end(), updated.metadata().begin(), - updated.metadata().end(), - std::inserter(difference, difference.end()), - // We want to compare just keys and ignore values, the - // map class provides such a function, so use it: - original.metadata().value_comp()); - for (auto&& d : difference) { - builder.ResetMetadata(d.first); - } - - // Find the elements (comparing key and value) in the updated map that - // are not in the original map: - difference.clear(); - std::set_difference(updated.metadata().begin(), updated.metadata().end(), - original.metadata().begin(), - original.metadata().end(), - std::inserter(difference, difference.end())); - for (auto&& d : difference) { - builder.SetMetadata(d.first, d.second); - } - } - } + DiffMetadata(builder, original, updated); + DiffContexts(builder, original, updated); if (original.temporary_hold() != updated.temporary_hold()) { builder.SetTemporaryHold(updated.temporary_hold()); diff --git a/google/cloud/storage/internal/object_requests_test.cc b/google/cloud/storage/internal/object_requests_test.cc index 4e25fc2d11036..791dd73fc1ecb 100644 --- a/google/cloud/storage/internal/object_requests_test.cc +++ b/google/cloud/storage/internal/object_requests_test.cc @@ -749,6 +749,15 @@ ObjectMetadata CreateObjectMetadataForTest() { "foo": "bar", "baz": "qux" }, + "contexts": { + "custom": { + "environment": { + "value": "prod", + "createTime": "2024-07-18T00:00:00Z", + "updateTime": "2024-07-18T00:00:00Z" + } + } + }, "metageneration": "4", "name": "baz", "owner": { @@ -964,6 +973,66 @@ TEST(PatchObjectRequestTest, DiffResetMetadata) { EXPECT_EQ(expected, patch); } +TEST(PatchObjectRequestTest, DiffSetContexts) { + ObjectMetadata original = CreateObjectMetadataForTest(); + + ObjectMetadata updated = original; + ObjectContexts contexts; + contexts.upsert("department", {"engineering", {}, {}}) + .upsert("environment", {"preprod", {}, {}}); + updated.set_contexts(contexts); + + PatchObjectRequest request("test-bucket", "test-object", original, updated); + + auto patch = nlohmann::json::parse(request.payload()); + auto expected = nlohmann::json::parse(R"""({ + "contexts": { + "custom": { + "environment": { + "value": "preprod" + }, + "department": { + "value": "engineering" + } + } + } + })"""); + EXPECT_EQ(expected, patch); +} + +TEST(PatchObjectRequestTest, DiffResetOneContext) { + ObjectMetadata original = CreateObjectMetadataForTest(); + + ObjectMetadata updated = original; + ObjectContexts contexts; + contexts.delete_key("environment"); + updated.set_contexts(contexts); + + PatchObjectRequest request("test-bucket", "test-object", original, updated); + + auto patch = nlohmann::json::parse(request.payload()); + auto expected = nlohmann::json::parse(R"""({ + "contexts": { + "custom": { + "environment": null + } + } + })"""); + EXPECT_EQ(expected, patch); +} + +TEST(PatchObjectRequestTest, DiffResetContexts) { + ObjectMetadata original = CreateObjectMetadataForTest(); + ObjectMetadata updated = original; + updated.reset_contexts(); + PatchObjectRequest request("test-bucket", "test-object", original, updated); + + auto patch = nlohmann::json::parse(request.payload()); + auto expected = + nlohmann::json::parse(R"""({"contexts": {"custom": null}})"""); + EXPECT_EQ(expected, patch); +} + TEST(PatchObjectRequestTest, DiffSetTemporaryHold) { ObjectMetadata original = CreateObjectMetadataForTest(); original.set_temporary_hold(false); diff --git a/google/cloud/storage/internal/patch_builder_details.cc b/google/cloud/storage/internal/patch_builder_details.cc index 9a4ab7c8db5a9..fe634a54b717d 100644 --- a/google/cloud/storage/internal/patch_builder_details.cc +++ b/google/cloud/storage/internal/patch_builder_details.cc @@ -62,6 +62,15 @@ nlohmann::json const& PatchBuilderDetails::GetMetadataSubPatch( return GetPatch(patch.metadata_subpatch_); } +nlohmann::json const& PatchBuilderDetails::GetCustomContextsSubPatch( + storage::ObjectMetadataPatchBuilder const& patch) { + static auto const* const kEmpty = [] { + return new nlohmann::json(nlohmann::json::object()); + }(); + if (!patch.contexts_subpatch_dirty_) return *kEmpty; + return GetPatch(patch.contexts_custom_subpatch_); +} + } // namespace internal GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace storage diff --git a/google/cloud/storage/internal/patch_builder_details.h b/google/cloud/storage/internal/patch_builder_details.h index 4231b156f5560..681cc8e92ba58 100644 --- a/google/cloud/storage/internal/patch_builder_details.h +++ b/google/cloud/storage/internal/patch_builder_details.h @@ -53,6 +53,8 @@ struct PatchBuilderDetails { storage::ObjectMetadataPatchBuilder const& patch); static nlohmann::json const& GetMetadataSubPatch( storage::ObjectMetadataPatchBuilder const& patch); + static nlohmann::json const& GetCustomContextsSubPatch( + storage::ObjectMetadataPatchBuilder const& patch); static nlohmann::json const& GetPatch(PatchBuilder const& patch); }; diff --git a/google/cloud/storage/object_contexts.cc b/google/cloud/storage/object_contexts.cc new file mode 100644 index 0000000000000..182e2a6409383 --- /dev/null +++ b/google/cloud/storage/object_contexts.cc @@ -0,0 +1,101 @@ +// 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 "google/cloud/internal/throw_delegate.h" +#include +#include + +namespace google { +namespace cloud { +namespace storage { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN + +namespace internal { + +void ValidateObjectContext(std::string const& key, std::string const& value) { + // Helper lambda to validate shared rules for both keys and values + auto validate_component = [](std::string const& str, char const* name) { + if (str.empty() || str.size() > 256) { + google::cloud::internal::ThrowInvalidArgument( + std::string("Object context ") + name + + " must be between 1 and 256 bytes."); + } + if (!std::isalnum(static_cast(str.front()))) { + google::cloud::internal::ThrowInvalidArgument( + std::string("Object context ") + name + + " must begin with an alphanumeric character."); + } + if (str.find_first_of("'\"\\/") != std::string::npos) { + google::cloud::internal::ThrowInvalidArgument( + std::string("Object context ") + name + + " cannot contain ', \", \\, or /."); + } + }; + + validate_component(key, "keys"); + validate_component(value, "values"); + + // Rule specific to keys: Cannot begin with 'goog' + if (key.size() >= 4 && key.compare(0, 4, "goog") == 0) { + google::cloud::internal::ThrowInvalidArgument( + "Object context keys cannot begin with 'goog'."); + } +} + +void ValidateObjectContextsAggregate(ObjectContexts const& contexts) { + // Validate each individual key-value pair and calculate aggregate size. + for (auto const& kv : contexts.custom()) { + ValidateObjectContext(kv.first, kv.second.value); + } + + // Rule: Count limited to 50. + if (contexts.custom().size() > 50) { + google::cloud::internal::ThrowInvalidArgument( + "Object contexts are limited to 50 entries per object."); + } + + // Note: The API limits the aggregate size to 25 KiB (25,600 bytes). + // With a max of 50 items and a max of 256 bytes per key and value, + // the maximum possible size is exactly 50 * (256 + 256) = 25,600 bytes. + // Therefore, an explicit aggregate size check is mathematically redundant. +} + +} // namespace internal + +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={"; + char const* sep = ""; + for (auto const& kv : rhs.custom()) { + os << sep << kv.first << "=" << kv.second.value; + sep = ",\n"; + } + + 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..97b335c99d2b2 --- /dev/null +++ b/google/cloud/storage/object_contexts.h @@ -0,0 +1,114 @@ +// 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 { + // The value of the object context. + std::string value; + + // The time at which the object context was created. Output only. + std::chrono::system_clock::time_point create_time; + + // The time at which the object context was last updated. Output only. + 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); +} + +inline bool operator<(ObjectCustomContextPayload const& lhs, + ObjectCustomContextPayload const& rhs) { + return lhs.value < rhs.value; +} + +std::ostream& operator<<(std::ostream& os, + ObjectCustomContextPayload const& rhs); + +/** + * Specifies the custom contexts of an object. + */ +struct ObjectContexts { + public: + bool has_key(std::string const& key) const { + return custom_.find(key) != custom_.end(); + } + + ObjectContexts& upsert(std::string const& key, + ObjectCustomContextPayload const& value) { + custom_[key] = value; + return *this; + } + + bool delete_key(std::string const& key) { return custom_.erase(key) > 0; } + + std::map const& custom() const { + return custom_; + } + + bool operator==(ObjectContexts const& other) const { + return custom_ == other.custom_; + } + + bool operator!=(ObjectContexts const& other) const { + return !(*this == other); + } + + private: + /** + * Represents the map of user-defined object contexts. + */ + std::map custom_; +}; + +std::ostream& operator<<(std::ostream& os, ObjectContexts const& rhs); + +namespace internal { +// Validates a single context key/value pair and throws on invalid inputs. +void ValidateObjectContext(std::string const& key, std::string const& value); + +// Validates the aggregate constraints (size & count) and all individual pairs. +// Throws on invalid inputs. +void ValidateObjectContextsAggregate(ObjectContexts const& contexts); +} // namespace internal + +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_contexts_test.cc b/google/cloud/storage/object_contexts_test.cc new file mode 100644 index 0000000000000..e4125ca716381 --- /dev/null +++ b/google/cloud/storage/object_contexts_test.cc @@ -0,0 +1,123 @@ +// 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 +#include +#include +#include + +namespace google { +namespace cloud { +namespace storage { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN +namespace internal { +namespace { + +// ============================================================================ +// Happy Path Tests: These run unconditionally. +// ============================================================================ + +TEST(ObjectContextsTest, ValidContexts) { + // Typical valid key-value pairs + ValidateObjectContext("validKey1", "validValue1"); + ValidateObjectContext("a", "b"); + + // Exact 256-byte limits + ValidateObjectContext(std::string(256, 'k'), std::string(256, 'v')); +} + +TEST(ObjectContextsTest, ReservedPrefixValid) { + // Similar but valid keys + ValidateObjectContext("goodKey", "value"); + ValidateObjectContext("goo", "value"); +} + +TEST(ObjectContextsTest, AggregateCountLimit) { + ObjectContexts contexts; + + // Insert exactly 50 entries + for (int i = 0; i < 50; ++i) { + contexts.upsert("k" + std::to_string(i), + ObjectCustomContextPayload{"v", {}, {}}); + } + + // Should pass validation unconditionally + ValidateObjectContextsAggregate(contexts); +} + +// ============================================================================ +// Sad Path Tests: These expect validation failures and throw exceptions. +// They are completely stripped out when compiled with -fno-exceptions. +// ============================================================================ + +#if GOOGLE_CLOUD_CPP_HAVE_EXCEPTIONS + +TEST(ObjectContextsTest, LengthLimits) { + // Empty strings + EXPECT_THROW(ValidateObjectContext("", "value"), std::invalid_argument); + EXPECT_THROW(ValidateObjectContext("key", ""), std::invalid_argument); + + // Exceeding 256 bytes + EXPECT_THROW(ValidateObjectContext(std::string(257, 'k'), "value"), + std::invalid_argument); + EXPECT_THROW(ValidateObjectContext("key", std::string(257, 'v')), + std::invalid_argument); +} + +TEST(ObjectContextsTest, StartingCharacters) { + // Cannot start with non-alphanumeric characters + EXPECT_THROW(ValidateObjectContext("-key", "value"), std::invalid_argument); + EXPECT_THROW(ValidateObjectContext("_key", "value"), std::invalid_argument); + EXPECT_THROW(ValidateObjectContext("key", ".value"), std::invalid_argument); + EXPECT_THROW(ValidateObjectContext("key", "@value"), std::invalid_argument); +} + +TEST(ObjectContextsTest, ForbiddenCharacters) { + // Contains ', ", \, or / + EXPECT_THROW(ValidateObjectContext("ke'y", "value"), std::invalid_argument); + EXPECT_THROW(ValidateObjectContext("key", "va\"lue"), std::invalid_argument); + EXPECT_THROW(ValidateObjectContext("ke\\y", "value"), std::invalid_argument); + EXPECT_THROW(ValidateObjectContext("key", "val/ue"), std::invalid_argument); +} + +TEST(ObjectContextsTest, ReservedPrefix) { + // Cannot begin with 'goog' + EXPECT_THROW(ValidateObjectContext("googKey", "value"), + std::invalid_argument); + EXPECT_THROW(ValidateObjectContext("google", "value"), std::invalid_argument); + EXPECT_THROW(ValidateObjectContext("goog", "value"), std::invalid_argument); +} + +TEST(ObjectContextsTest, AggregateCountLimitBreached) { + ObjectContexts contexts; + + // Breaching the limit (51 entries) + for (int i = 0; i < 51; ++i) { + contexts.upsert("k" + std::to_string(i), + ObjectCustomContextPayload{"v", {}, {}}); + } + + EXPECT_THROW(ValidateObjectContextsAggregate(contexts), + std::invalid_argument); +} + +#endif // GOOGLE_CLOUD_CPP_HAVE_EXCEPTIONS + +} // namespace +} // namespace internal +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END +} // namespace storage +} // namespace cloud +} // namespace google diff --git a/google/cloud/storage/object_metadata.cc b/google/cloud/storage/object_metadata.cc index 68e18262ce9d9..bbfdf6a32c93d 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 << "}"; } @@ -145,6 +149,15 @@ std::string ObjectMetadataPatchBuilder::BuildPatch() const { tmp.AddSubPatch("metadata", metadata_subpatch_); } } + if (contexts_subpatch_dirty_) { + if (contexts_custom_subpatch_.empty()) { + tmp.AddSubPatch("contexts", + internal::PatchBuilder().RemoveField("custom")); + } else { + tmp.AddSubPatch("contexts", internal::PatchBuilder().AddSubPatch( + "custom", contexts_custom_subpatch_)); + } + } return tmp.ToString(); } @@ -271,6 +284,30 @@ ObjectMetadataPatchBuilder& ObjectMetadataPatchBuilder::ResetMetadata() { return *this; } +ObjectMetadataPatchBuilder& ObjectMetadataPatchBuilder::SetContext( + std::string const& key, std::string const& value) { + // Throws if the key or value is invalid. + internal::ValidateObjectContext(key, value); + + contexts_custom_subpatch_.AddSubPatch( + key.c_str(), internal::PatchBuilder().SetStringField("value", value)); + contexts_subpatch_dirty_ = true; + return *this; +} + +ObjectMetadataPatchBuilder& ObjectMetadataPatchBuilder::ResetContext( + std::string const& key) { + contexts_custom_subpatch_.RemoveField(key.c_str()); + contexts_subpatch_dirty_ = true; + return *this; +} + +ObjectMetadataPatchBuilder& ObjectMetadataPatchBuilder::ResetContexts() { + contexts_custom_subpatch_.clear(); + contexts_subpatch_dirty_ = true; + return *this; +} + ObjectMetadataPatchBuilder& ObjectMetadataPatchBuilder::SetTemporaryHold( bool v) { impl_.SetBoolField("temporaryHold", v); diff --git a/google/cloud/storage/object_metadata.h b/google/cloud/storage/object_metadata.h index bc40bb6706899..1148b2cd3545e 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,30 @@ 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) { + internal::ValidateObjectContextsAggregate(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 +637,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_; @@ -675,6 +701,11 @@ class ObjectMetadataPatchBuilder { ObjectMetadataPatchBuilder& ResetMetadata(std::string const& key); ObjectMetadataPatchBuilder& ResetMetadata(); + ObjectMetadataPatchBuilder& SetContext(std::string const& key, + std::string const& value); + ObjectMetadataPatchBuilder& ResetContext(std::string const& key); + ObjectMetadataPatchBuilder& ResetContexts(); + ObjectMetadataPatchBuilder& SetTemporaryHold(bool v); ObjectMetadataPatchBuilder& ResetTemporaryHold(); @@ -703,6 +734,8 @@ class ObjectMetadataPatchBuilder { internal::PatchBuilder impl_; bool metadata_subpatch_dirty_{false}; internal::PatchBuilder metadata_subpatch_; + bool contexts_subpatch_dirty_{false}; + internal::PatchBuilder contexts_custom_subpatch_; }; /** diff --git a/google/cloud/storage/object_metadata_test.cc b/google/cloud/storage/object_metadata_test.cc index 1cc291cb4cc90..89171a42bcaf2 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,13 @@ TEST(ObjectMetadataTest, Parse) { EXPECT_EQ(actual.hard_delete_time(), std::chrono::system_clock::from_time_t(1710160496L) + std::chrono::milliseconds(789)); + 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 +283,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 +327,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 +375,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 +420,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 +466,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 +687,29 @@ 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("department", context_payload); + copy.set_contexts(contexts); + EXPECT_TRUE(copy.has_contexts()); + 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/storage_client_unit_tests.bzl b/google/cloud/storage/storage_client_unit_tests.bzl index 54c1c64a555b6..29c28f24ca7fe 100644 --- a/google/cloud/storage/storage_client_unit_tests.bzl +++ b/google/cloud/storage/storage_client_unit_tests.bzl @@ -98,6 +98,7 @@ storage_client_unit_tests = [ "list_objects_reader_test.cc", "notification_metadata_test.cc", "object_access_control_test.cc", + "object_contexts_test.cc", "object_metadata_test.cc", "object_retention_test.cc", "object_stream_test.cc", 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..01d26169403a1 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,34 @@ #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{}; +} + +void AssertHasCustomContext(google::cloud::storage::ObjectMetadata const& meta, + std::string const& key, + std::string const& expected_value) { + EXPECT_TRUE(meta.has_contexts()) + << "Missing contexts entirely in metadata: " << meta; + EXPECT_TRUE(meta.contexts().has_key(key)) + << "Missing expected context key '" << key << "' in metadata: " << meta; + if (meta.contexts().has_key(key)) { + EXPECT_EQ(expected_value, meta.contexts().custom().at(key).value) + << "Mismatch in context value for key '" << key << "'\n" + << "Expecting value '" << expected_value << "'\n" + << "Actual metadata: " << meta; + EXPECT_TRUE(IsSet(meta.contexts().custom().at(key).create_time)) + << "The create_time of key '" << key << "' is not set.\n" + << "Actual metadata: " << meta; + EXPECT_TRUE(IsSet(meta.contexts().custom().at(key).update_time)) + << "The update_time of key '" << key << "' is not set.\n" + << "Actual metadata: " << meta; + } +} +} // namespace + namespace google { namespace cloud { namespace storage { @@ -93,6 +121,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 +185,86 @@ 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."; + + // 1. Insert Object with custom contexts. + StatusOr insert_meta = client.InsertObject( + bucket_name_, object_name, LoremIpsum(), IfGenerationMatch(0), + Projection("full"), + WithObjectMetadata(ObjectMetadata().set_contexts( + ObjectContexts().upsert("department", {"engineering", {}, {}})))); + ASSERT_STATUS_OK(insert_meta); + EXPECT_THAT(list_object_names(), Contains(object_name).Times(1)); + StatusOr get_meta = client.GetObjectMetadata( + bucket_name_, object_name, Generation(insert_meta->generation()), + Projection("full")); + ASSERT_STATUS_OK(get_meta); + AssertHasCustomContext(*get_meta, "department", "engineering"); + + // 2. Update object with two keys. + ObjectMetadata update = *get_meta; + update.set_contexts( + ObjectContexts() + .upsert("department", {"engineering and research", {}, {}}) + .upsert("region", {"Asia Pacific", {}, {}})); + StatusOr updated_meta = client.UpdateObject( + bucket_name_, object_name, update, Generation(get_meta->generation()), + Projection("full")); + ASSERT_STATUS_OK(updated_meta); + AssertHasCustomContext(*updated_meta, "department", + "engineering and research"); + AssertHasCustomContext(*updated_meta, "region", "Asia Pacific"); + + // 3. Patch the object contexts by updating one key's value. + StatusOr patched_meta = + client.PatchObject(bucket_name_, object_name, + ObjectMetadataPatchBuilder().SetContext( + "region", {"Asia Pacific - Singapore"}), + Projection("full")); + ASSERT_STATUS_OK(patched_meta); + AssertHasCustomContext(*patched_meta, "department", + "engineering and research"); + AssertHasCustomContext(*patched_meta, "region", "Asia Pacific - Singapore"); + + // 4. Patch object contexts by deleting one existing key. + StatusOr reset_key_meta = client.PatchObject( + bucket_name_, object_name, + ObjectMetadataPatchBuilder().ResetContext("region"), Projection("full")); + ASSERT_STATUS_OK(reset_key_meta); + AssertHasCustomContext(*reset_key_meta, "department", + "engineering and research"); + EXPECT_FALSE(reset_key_meta->contexts().has_key("region")); + + // 5. Patch object with reset of all contexts. + StatusOr reset_meta = client.PatchObject( + bucket_name_, object_name, ObjectMetadataPatchBuilder().ResetContexts(), + Projection("full")); + ASSERT_STATUS_OK(reset_meta); + EXPECT_FALSE(reset_meta->has_contexts()) << *reset_meta; + + // 6. Delete the object away to clean up. + 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();