Skip to content

Commit 7985f2e

Browse files
authored
feat(GCS+gRPC): implement UpdateBucket() (googleapis#8416)
Since this uses the same RPC as `PatchBucket()` I just needed to implement the `ToProto()` helper, the RPC itself was easy after that. As usual, I also implemented the unit and integration test for this new RPC.
1 parent d5f63b1 commit 7985f2e

File tree

6 files changed

+311
-2
lines changed

6 files changed

+311
-2
lines changed

google/cloud/storage/internal/grpc_bucket_request_parser.cc

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,131 @@ GrpcBucketRequestParser::ToProto(PatchBucketRequest const& request) {
308308
return result;
309309
}
310310

311+
google::storage::v2::UpdateBucketRequest GrpcBucketRequestParser::ToProto(
312+
UpdateBucketRequest const& request) {
313+
google::storage::v2::UpdateBucketRequest result;
314+
315+
auto& bucket = *result.mutable_bucket();
316+
auto const& metadata = request.metadata();
317+
bucket.set_name("projects/_/buckets/" + metadata.name());
318+
319+
// We set the update_mask for all fields, even if not present in `metadata` as
320+
// "not present" implies the field should be cleared.
321+
322+
result.mutable_update_mask()->add_paths("storage_class");
323+
bucket.set_storage_class(metadata.storage_class());
324+
result.mutable_update_mask()->add_paths("rpo");
325+
bucket.set_rpo(metadata.rpo());
326+
result.mutable_update_mask()->add_paths("acl");
327+
for (auto const& a : metadata.acl()) {
328+
auto& acl = *bucket.add_acl();
329+
acl.set_entity(a.entity());
330+
acl.set_role(a.role());
331+
}
332+
result.mutable_update_mask()->add_paths("default_object_acl");
333+
for (auto const& a : metadata.default_acl()) {
334+
auto& acl = *bucket.add_default_object_acl();
335+
acl.set_entity(a.entity());
336+
acl.set_role(a.role());
337+
}
338+
result.mutable_update_mask()->add_paths("lifecycle");
339+
if (metadata.has_lifecycle()) {
340+
auto& lifecycle = *bucket.mutable_lifecycle();
341+
// By construction, the PatchBuilder always includes the "rule"
342+
// subobject.
343+
for (auto const& r : metadata.lifecycle().rule) {
344+
*lifecycle.add_rule() = GrpcBucketMetadataParser::ToProto(r);
345+
}
346+
}
347+
result.mutable_update_mask()->add_paths("cors");
348+
for (auto const& c : metadata.cors()) {
349+
auto& cors = *bucket.add_cors();
350+
cors.set_max_age_seconds(
351+
static_cast<std::int32_t>(c.max_age_seconds.value_or(0)));
352+
for (auto const& o : c.origin) {
353+
cors.add_origin(o);
354+
}
355+
for (auto const& m : c.method) {
356+
cors.add_method(m);
357+
}
358+
for (auto const& h : c.response_header) {
359+
cors.add_response_header(h);
360+
}
361+
}
362+
result.mutable_update_mask()->add_paths("default_event_based_hold");
363+
bucket.set_default_event_based_hold(metadata.default_event_based_hold());
364+
result.mutable_update_mask()->add_paths("labels");
365+
for (auto const& kv : metadata.labels()) {
366+
(*bucket.mutable_labels())[kv.first] = kv.second;
367+
}
368+
result.mutable_update_mask()->add_paths("website");
369+
if (metadata.has_website()) {
370+
auto const& w = metadata.website();
371+
bucket.mutable_website()->set_main_page_suffix(w.main_page_suffix);
372+
bucket.mutable_website()->set_not_found_page(w.not_found_page);
373+
}
374+
result.mutable_update_mask()->add_paths("versioning");
375+
if (metadata.has_versioning()) {
376+
bucket.mutable_versioning()->set_enabled(metadata.versioning()->enabled);
377+
}
378+
result.mutable_update_mask()->add_paths("logging");
379+
if (metadata.has_logging()) {
380+
bucket.mutable_logging()->set_log_bucket(metadata.logging().log_bucket);
381+
bucket.mutable_logging()->set_log_object_prefix(
382+
metadata.logging().log_object_prefix);
383+
}
384+
result.mutable_update_mask()->add_paths("encryption");
385+
if (metadata.has_encryption()) {
386+
bucket.mutable_encryption()->set_default_kms_key(
387+
metadata.encryption().default_kms_key_name);
388+
}
389+
result.mutable_update_mask()->add_paths("billing");
390+
if (metadata.has_billing()) {
391+
bucket.mutable_billing()->set_requester_pays(
392+
metadata.billing().requester_pays);
393+
}
394+
result.mutable_update_mask()->add_paths("retention_policy");
395+
if (metadata.has_retention_policy()) {
396+
bucket.mutable_retention_policy()->set_retention_period(
397+
metadata.retention_policy().retention_period.count());
398+
}
399+
result.mutable_update_mask()->add_paths("iam_config");
400+
if (metadata.has_iam_configuration()) {
401+
auto& iam_config = *bucket.mutable_iam_config();
402+
auto const& i = metadata.iam_configuration();
403+
if (i.uniform_bucket_level_access.has_value()) {
404+
iam_config.mutable_uniform_bucket_level_access()->set_enabled(
405+
i.uniform_bucket_level_access->enabled);
406+
}
407+
if (i.public_access_prevention.has_value()) {
408+
auto pap = i.public_access_prevention.value();
409+
iam_config.set_public_access_prevention(
410+
GrpcBucketMetadataParser::ToProtoPublicAccessPrevention(pap));
411+
}
412+
}
413+
414+
if (request.HasOption<IfMetagenerationMatch>()) {
415+
result.set_if_metageneration_match(
416+
request.GetOption<IfMetagenerationMatch>().value());
417+
}
418+
if (request.HasOption<IfMetagenerationNotMatch>()) {
419+
result.set_if_metageneration_not_match(
420+
request.GetOption<IfMetagenerationNotMatch>().value());
421+
}
422+
if (request.HasOption<PredefinedAcl>()) {
423+
result.set_predefined_acl(GrpcPredefinedAclParser::ToProtoBucket(
424+
request.GetOption<PredefinedAcl>()));
425+
}
426+
if (request.HasOption<PredefinedDefaultObjectAcl>()) {
427+
result.set_predefined_default_object_acl(
428+
GrpcPredefinedAclParser::ToProtoObject(
429+
request.GetOption<PredefinedDefaultObjectAcl>()));
430+
}
431+
SetCommonParameters(result, request);
432+
433+
return result;
434+
}
435+
311436
} // namespace internal
312437
GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END
313438
} // namespace storage

google/cloud/storage/internal/grpc_bucket_request_parser.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ struct GrpcBucketRequestParser {
4343

4444
static StatusOr<google::storage::v2::UpdateBucketRequest> ToProto(
4545
PatchBucketRequest const& request);
46+
static google::storage::v2::UpdateBucketRequest ToProto(
47+
UpdateBucketRequest const& request);
4648
};
4749

4850
} // namespace internal

google/cloud/storage/internal/grpc_bucket_request_parser_test.cc

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,154 @@ TEST(GrpcBucketRequestParser, PatchBucketRequestAllOptions) {
361361
EXPECT_THAT(*actual, IsProtoEqual(expected));
362362
}
363363

364+
TEST(GrpcBucketRequestParser, UpdateBucketRequestAllOptions) {
365+
google::storage::v2::UpdateBucketRequest expected;
366+
ASSERT_TRUE(google::protobuf::TextFormat::ParseFromString(
367+
R"pb(
368+
bucket {
369+
name: "projects/_/buckets/bucket-name"
370+
storage_class: "NEARLINE"
371+
rpo: "ASYNC_TURBO"
372+
acl { entity: "allUsers" role: "READER" }
373+
default_object_acl { entity: "user:[email protected]" role: "WRITER" }
374+
lifecycle {
375+
rule {
376+
action { type: "Delete" }
377+
condition {
378+
age_days: 90
379+
created_before { year: 2022 month: 2 day: 2 }
380+
is_live: true
381+
num_newer_versions: 7
382+
matches_storage_class: "STANDARD"
383+
days_since_custom_time: 42
384+
days_since_noncurrent_time: 84
385+
noncurrent_time_before { year: 2022 month: 2 day: 15 }
386+
}
387+
}
388+
}
389+
cors {
390+
origin: "test-origin-0"
391+
origin: "test-origin-1"
392+
method: "GET"
393+
method: "PUT"
394+
response_header: "test-header-0"
395+
response_header: "test-header-1"
396+
max_age_seconds: 1800
397+
}
398+
cors { origin: "test-origin-0" origin: "test-origin-1" }
399+
cors { method: "GET" method: "PUT" }
400+
cors {
401+
response_header: "test-header-0"
402+
response_header: "test-header-1"
403+
}
404+
cors { max_age_seconds: 1800 }
405+
default_event_based_hold: true
406+
labels { key: "key0" value: "value0" }
407+
website { main_page_suffix: "index.html" not_found_page: "404.html" }
408+
versioning { enabled: true }
409+
logging {
410+
log_bucket: "test-log-bucket"
411+
log_object_prefix: "test-log-prefix"
412+
}
413+
encryption { default_kms_key: "test-only-kms-key" }
414+
billing { requester_pays: true }
415+
retention_policy { retention_period: 123000 }
416+
iam_config {
417+
uniform_bucket_level_access { enabled: true }
418+
public_access_prevention: ENFORCED
419+
}
420+
}
421+
predefined_acl: BUCKET_ACL_PROJECT_PRIVATE
422+
predefined_default_object_acl: OBJECT_ACL_PROJECT_PRIVATE
423+
if_metageneration_match: 3
424+
if_metageneration_not_match: 4
425+
common_request_params: { user_project: "test-user-project" }
426+
update_mask {}
427+
)pb",
428+
&expected));
429+
430+
UpdateBucketRequest req(
431+
BucketMetadata{}
432+
.set_name("bucket-name")
433+
.set_storage_class("NEARLINE")
434+
.set_rpo(RpoAsyncTurbo())
435+
.set_acl(
436+
{BucketAccessControl{}.set_entity("allUsers").set_role("READER")})
437+
.set_default_acl({ObjectAccessControl{}
438+
.set_entity("user:[email protected]")
439+
.set_role("WRITER")})
440+
.set_lifecycle(BucketLifecycle{{LifecycleRule(
441+
LifecycleRule::ConditionConjunction(
442+
LifecycleRule::MaxAge(90),
443+
LifecycleRule::CreatedBefore(absl::CivilDay(2022, 2, 2)),
444+
LifecycleRule::IsLive(true),
445+
LifecycleRule::NumNewerVersions(7),
446+
LifecycleRule::MatchesStorageClassStandard(),
447+
LifecycleRule::DaysSinceCustomTime(42),
448+
LifecycleRule::DaysSinceNoncurrentTime(84),
449+
LifecycleRule::NoncurrentTimeBefore(
450+
absl::CivilDay(2022, 2, 15))),
451+
LifecycleRule::Delete())}})
452+
.set_cors({
453+
CorsEntry{
454+
/*.max_age_seconds=*/absl::make_optional(std::uint64_t(1800)),
455+
/*.method=*/{"GET", "PUT"},
456+
/*.origin=*/{"test-origin-0", "test-origin-1"},
457+
/*.response_header=*/{"test-header-0", "test-header-1"}},
458+
CorsEntry{/*.max_age_seconds=*/absl::nullopt,
459+
/*.method=*/{},
460+
/*.origin=*/{"test-origin-0", "test-origin-1"},
461+
/*.response_header=*/{}},
462+
CorsEntry{/*.max_age_seconds=*/absl::nullopt,
463+
/*.method=*/{"GET", "PUT"},
464+
/*.origin=*/{},
465+
/*.response_header=*/{}},
466+
CorsEntry{
467+
/*.max_age_seconds=*/absl::nullopt,
468+
/*.method=*/{},
469+
/*.origin=*/{},
470+
/*.response_header=*/{"test-header-0", "test-header-1"}},
471+
CorsEntry{
472+
/*.max_age_seconds=*/absl::make_optional(std::uint64_t(1800)),
473+
/*.method=*/{},
474+
/*.origin=*/{},
475+
/*.response_header=*/{}},
476+
})
477+
.set_default_event_based_hold(true)
478+
.upsert_label("key0", "value0")
479+
.set_website(BucketWebsite{"index.html", "404.html"})
480+
.set_versioning(BucketVersioning{true})
481+
.set_logging(BucketLogging{"test-log-bucket", "test-log-prefix"})
482+
.set_encryption(
483+
BucketEncryption{/*.default_kms_key=*/"test-only-kms-key"})
484+
.set_billing(BucketBilling{/*.requester_pays=*/true})
485+
.set_retention_policy(std::chrono::seconds(123000))
486+
.set_iam_configuration(BucketIamConfiguration{
487+
/*.uniform_bucket_level_access=*/UniformBucketLevelAccess{true,
488+
{}},
489+
/*.public_access_prevention=*/PublicAccessPreventionEnforced()}));
490+
req.set_multiple_options(
491+
IfMetagenerationMatch(3), IfMetagenerationNotMatch(4),
492+
PredefinedAcl("projectPrivate"),
493+
PredefinedDefaultObjectAcl("projectPrivate"), Projection("full"),
494+
UserProject("test-user-project"), QuotaUser("test-quota-user"),
495+
UserIp("test-user-ip"));
496+
497+
auto actual = GrpcBucketRequestParser::ToProto(req);
498+
// First check the paths, we do not care about their order, so checking them
499+
// with IsProtoEqual does not work.
500+
EXPECT_THAT(actual.update_mask().paths(),
501+
UnorderedElementsAre(
502+
"storage_class", "rpo", "acl", "default_object_acl",
503+
"lifecycle", "cors", "default_event_based_hold", "labels",
504+
"website", "versioning", "logging", "encryption", "billing",
505+
"retention_policy", "iam_config"));
506+
507+
// Clear the paths, which we already compared, and test the rest
508+
actual.mutable_update_mask()->clear_paths();
509+
EXPECT_THAT(actual, IsProtoEqual(expected));
510+
}
511+
364512
} // namespace
365513
} // namespace internal
366514
GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END

google/cloud/storage/internal/grpc_client.cc

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,14 @@ StatusOr<EmptyResponse> GrpcClient::DeleteBucket(
191191
return EmptyResponse{};
192192
}
193193

194-
StatusOr<BucketMetadata> GrpcClient::UpdateBucket(UpdateBucketRequest const&) {
195-
return Status(StatusCode::kUnimplemented, __func__);
194+
StatusOr<BucketMetadata> GrpcClient::UpdateBucket(
195+
UpdateBucketRequest const& request) {
196+
auto proto = GrpcBucketRequestParser::ToProto(request);
197+
grpc::ClientContext context;
198+
ApplyQueryParameters(context, request);
199+
auto response = stub_->UpdateBucket(context, proto);
200+
if (!response) return std::move(response).status();
201+
return GrpcBucketMetadataParser::FromProto(*response);
196202
}
197203

198204
StatusOr<BucketMetadata> GrpcClient::PatchBucket(

google/cloud/storage/internal/grpc_client_test.cc

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,27 @@ TEST_F(GrpcClientTest, ListBuckets) {
161161
EXPECT_EQ(response.status(), PermanentError());
162162
}
163163

164+
TEST_F(GrpcClientTest, UpdateBucket) {
165+
auto mock = std::make_shared<testing::MockStorageStub>();
166+
EXPECT_CALL(*mock, UpdateBucket)
167+
.WillOnce([this](
168+
grpc::ClientContext& context,
169+
google::storage::v2::UpdateBucketRequest const& request) {
170+
auto metadata = GetMetadata(context);
171+
EXPECT_THAT(metadata, UnorderedElementsAre(
172+
Pair("x-goog-quota-user", "test-quota-user"),
173+
Pair("x-goog-fieldmask", "field1,field2")));
174+
EXPECT_THAT(request.bucket().name(), "projects/_/buckets/test-bucket");
175+
return PermanentError();
176+
});
177+
auto client = CreateTestClient(mock);
178+
auto response = client->UpdateBucket(
179+
UpdateBucketRequest(BucketMetadata{}.set_name("test-bucket"))
180+
.set_multiple_options(Fields("field1,field2"),
181+
QuotaUser("test-quota-user")));
182+
EXPECT_EQ(response.status(), PermanentError());
183+
}
184+
164185
TEST_F(GrpcClientTest, PatchBucket) {
165186
auto mock = std::make_shared<testing::MockStorageStub>();
166187
EXPECT_CALL(*mock, UpdateBucket)

google/cloud/storage/tests/grpc_bucket_metadata_integration_test.cc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ using ::testing::IsEmpty;
3535
using ::testing::IsSupersetOf;
3636
using ::testing::Not;
3737
using ::testing::Pair;
38+
using ::testing::UnorderedElementsAre;
3839

3940
// When GOOGLE_CLOUD_CPP_HAVE_GRPC is not set these tests compile, but they
4041
// actually just run against the regular GCS REST API. That is fine.
@@ -80,6 +81,12 @@ TEST_F(GrpcBucketMetadataIntegrationTest, ObjectMetadataCRUD) {
8081
ASSERT_STATUS_OK(patch);
8182
EXPECT_THAT(patch->labels(), ElementsAre(Pair("l0", "k0")));
8283

84+
auto updated = client->UpdateBucket(
85+
patch->name(), BucketMetadata(*patch).upsert_label("l1", "test-value"));
86+
ASSERT_STATUS_OK(updated);
87+
EXPECT_THAT(updated->labels(),
88+
UnorderedElementsAre(Pair("l0", "k0"), Pair("l1", "test-value")));
89+
8390
// Create a second bucket to make the list more interesting.
8491
auto bucket_name_2 = MakeRandomBucketName();
8592
auto insert_2 = client->CreateBucketForProject(bucket_name_2, project_name,

0 commit comments

Comments
 (0)