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/object_metadata_parser.cc b/google/cloud/storage/internal/object_metadata_parser.cc index 4f5e7efa20795..7e56c2c9872dc 100644 --- a/google/cloud/storage/internal/object_metadata_parser.cc +++ b/google/cloud/storage/internal/object_metadata_parser.cc @@ -44,6 +44,29 @@ 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) { + custom_json[kv.first] = nlohmann::json{ + {"value", kv.second.value}, + {"createTime", + google::cloud::internal::FormatRfc3339(kv.second.create_time)}, + {"updateTime", + google::cloud::internal::FormatRfc3339(kv.second.update_time)}, + }; + } + + 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 +183,33 @@ 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()) { + 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.custom.emplace(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 +346,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 +423,8 @@ nlohmann::json ObjectMetadataJsonForCompose(ObjectMetadata const& meta) { meta.retention().retain_until_time)}}; } + SetJsonContextsIfNotEmpty(metadata_as_json, meta); + return metadata_as_json; } @@ -430,6 +483,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..47253a9826b44 --- /dev/null +++ b/google/cloud/storage/object_contexts.cc @@ -0,0 +1,46 @@ +// 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={"; + char const* sep = ""; + for (auto const& kv : rhs.custom) { + os << sep << kv.first << "=" << kv.second; + 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..bff11421d627c --- /dev/null +++ b/google/cloud/storage/object_contexts.h @@ -0,0 +1,94 @@ +// 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 +#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 { + /** + * Represents the map of user-defined object contexts, keyed by a string + * value. + */ + std::map custom; + + /** + * A set of helper functions to handle the custom. + */ + bool has_custom(std::string const& key) const { + return custom.end() != custom.find(key); + } + ObjectCustomContextPayload const& get_custom(std::string const& key) const { + return custom.at(key); + } + void upsert_custom(std::string const& key, + ObjectCustomContextPayload const& value) { + custom[key] = value; + } + void delete_custom(std::string const& key) { custom.erase(key); } +}; + +inline bool operator==(ObjectContexts const& lhs, ObjectContexts const& rhs) { + return lhs.custom == rhs.custom; +}; + +inline bool operator!=(ObjectContexts const& lhs, ObjectContexts const& rhs) { + return !(lhs == rhs); +} + +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..6b289d5438922 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,11 @@ 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 << "}"; } @@ -271,6 +277,31 @@ ObjectMetadataPatchBuilder& ObjectMetadataPatchBuilder::ResetMetadata() { return *this; } +ObjectMetadataPatchBuilder& ObjectMetadataPatchBuilder::SetContexts( + ObjectContexts const& tp) { + internal::PatchBuilder custom_subpatch; + for (auto const& pair : tp.custom) { + custom_subpatch.AddSubPatch( + pair.first.c_str(), + internal::PatchBuilder() + .SetStringField("value", pair.second.value) + .SetStringField( + "createTime", + google::cloud::internal::FormatRfc3339(pair.second.create_time)) + .SetStringField("updateTime", + google::cloud::internal::FormatRfc3339( + pair.second.update_time))); + } + impl_.AddSubPatch("contexts", internal::PatchBuilder().AddSubPatch( + "custom", custom_subpatch)); + return *this; +} + +ObjectMetadataPatchBuilder& ObjectMetadataPatchBuilder::ResetContexts() { + impl_.RemoveField("contexts"); + 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..0813b3e7f03f6 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 user-defined 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's custom 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_; @@ -675,6 +700,9 @@ class ObjectMetadataPatchBuilder { ObjectMetadataPatchBuilder& ResetMetadata(std::string const& key); ObjectMetadataPatchBuilder& ResetMetadata(); + ObjectMetadataPatchBuilder& SetContexts(ObjectContexts const& tp); + ObjectMetadataPatchBuilder& ResetContexts(); + ObjectMetadataPatchBuilder& SetTemporaryHold(bool v); ObjectMetadataPatchBuilder& ResetTemporaryHold(); diff --git a/google/cloud/storage/object_metadata_test.cc b/google/cloud/storage/object_metadata_test.cc index 1cc291cb4cc90..533a6cbf4f56f 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", @@ -175,7 +184,6 @@ TEST(ObjectMetadataTest, Parse) { ObjectRetentionUnlocked(), google::cloud::internal::ParseRfc3339("2024-07-18T00:00:00Z") .value()})); - EXPECT_EQ("https://storage.googleapis.com/storage/v1/b/foo-bar/o/baz", actual.self_link()); EXPECT_EQ(102400, actual.size()); @@ -207,6 +215,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()); + 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,33 @@ 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", + google::cloud::internal::ParseRfc3339("2025-07-18T00:00:00Z").value(), + google::cloud::internal::ParseRfc3339("2025-07-18T00:00:00Z").value()}; + std::map custom{ + {"department", context_payload}}; + auto const contexts = ObjectContexts{custom}; + copy.set_contexts(contexts); + EXPECT_TRUE(expected.has_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, ResetContexts) { + 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( @@ -821,6 +890,35 @@ TEST(ObjectMetadataPatchBuilder, ResetMetadata) { EXPECT_EQ(expected, actual_as_json) << actual; } +TEST(ObjectMetadataPatchBuilder, SetContexts) { + ObjectMetadataPatchBuilder builder; + std::chrono::system_clock::time_point time = + google::cloud::internal::ParseRfc3339("2026-02-01T00:00:00Z").value(); + ObjectContexts ctx; + ctx.upsert_custom("HOD", ObjectCustomContextPayload{"Alice", time, time}); + builder.SetContexts(ctx); + + auto actual = builder.BuildPatch(); + auto actual_as_json = nlohmann::json::parse(actual); + nlohmann::json expected{ + {"contexts", + {{"custom", nlohmann::json{{"HOD", + {{"createTime", "2026-02-01T00:00:00Z"}, + {"updateTime", "2026-02-01T00:00:00Z"}, + {"value", "Alice"}}}}}}}}; + EXPECT_EQ(expected, actual_as_json) << actual; +} + +TEST(ObjectMetadataPatchBuilder, ResetContexts) { + ObjectMetadataPatchBuilder builder; + builder.ResetContexts(); + + auto actual = builder.BuildPatch(); + auto actual_as_json = nlohmann::json::parse(actual); + nlohmann::json expected{{"contexts", nullptr}}; + EXPECT_EQ(expected, actual_as_json) << actual; +} + TEST(ObjectMetadataPatchBuilder, SetTemporaryHold) { ObjectMetadataPatchBuilder builder; builder.SetTemporaryHold(true);