From 830052b3f26337f50770436784f0ea9f3366dbca Mon Sep 17 00:00:00 2001 From: BenWhitehead Date: Wed, 5 Apr 2023 11:48:28 -0400 Subject: [PATCH] feat: implement GrpcStorageImpl#{get,list,create,delete}Notification (#1958) Rewrite ITNotificationTest to leverage testbench for grpc and to separate different cases into their own individual tests rather than having a single large test. For now the backend hasn't yet implemented the rpcs for grpc, so we rely on testbench. Once the backend does provide we can remove the CrossRun.Ignore annotation on each of the methods. --- .../cloud/storage/ApiaryConversions.java | 7 + .../google/cloud/storage/GrpcConversions.java | 55 +++++- .../google/cloud/storage/GrpcStorageImpl.java | 100 ++++++++-- .../google/cloud/storage/Notification.java | 6 + .../cloud/storage/NotificationInfo.java | 24 +++ .../com/google/cloud/storage/Storage.java | 8 +- .../java/com/google/cloud/storage/Utils.java | 20 ++ .../storage/NotificationInfoPropertyTest.java | 23 +++ .../cloud/storage/it/ITNotificationTest.java | 175 +++++++++++------- .../jqwik/NotificationArbitraryProvider.java | 94 ++++++++++ .../net.jqwik.api.providers.ArbitraryProvider | 1 + 11 files changed, 425 insertions(+), 88 deletions(-) create mode 100644 google-cloud-storage/src/test/java/com/google/cloud/storage/NotificationInfoPropertyTest.java create mode 100644 google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/NotificationArbitraryProvider.java diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ApiaryConversions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ApiaryConversions.java index 5b50a04e9..2fd275e8b 100644 --- a/google-cloud-storage/src/main/java/com/google/cloud/storage/ApiaryConversions.java +++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/ApiaryConversions.java @@ -89,6 +89,11 @@ final class ApiaryConversions { // when converting from gRPC to apiary or vice-versa we want to preserve this property. Until // such a time as the apiary model has a project field, we manually apply it with this name. private static final String PROJECT_ID_FIELD_NAME = "x_project"; + // gRPC has a NotificationConfig.name property which contains the bucket the config is associated + // with which that apiary doesn't have yet. + // when converting from gRPC to apiary or vice-versa we want to preserve this property. Until + // such a time as the apiary model has a bucket field, we manually apply it with this name. + private static final String NOTIFICATION_BUCKET_FIELD_NAME = "x_bucket"; private final Codec entityCodec = Codec.of(this::entityEncode, this::entityDecode); @@ -774,6 +779,7 @@ private com.google.api.services.storage.model.Notification notificationEncode( to.setEtag(from.getEtag()); to.setSelfLink(from.getSelfLink()); to.setTopic(from.getTopic()); + ifNonNull(from.getBucket(), b -> to.set(NOTIFICATION_BUCKET_FIELD_NAME, b)); ifNonNull(from.getNotificationId(), to::setId); ifNonNull(from.getCustomAttributes(), to::setCustomAttributes); ifNonNull(from.getObjectNamePrefix(), to::setObjectNamePrefix); @@ -799,6 +805,7 @@ private com.google.api.services.storage.model.Notification notificationEncode( private NotificationInfo notificationDecode( com.google.api.services.storage.model.Notification from) { NotificationInfo.Builder builder = new NotificationInfo.BuilderImpl(from.getTopic()); + ifNonNull(from.get(NOTIFICATION_BUCKET_FIELD_NAME), String.class::cast, builder::setBucket); ifNonNull(from.getId(), builder::setNotificationId); ifNonNull(from.getEtag(), builder::setEtag); ifNonNull(from.getCustomAttributes(), builder::setCustomAttributes); diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcConversions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcConversions.java index 69bd541db..38766b946 100644 --- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcConversions.java +++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcConversions.java @@ -20,6 +20,7 @@ import static com.google.cloud.storage.Utils.ifNonNull; import static com.google.cloud.storage.Utils.lift; import static com.google.cloud.storage.Utils.projectNameCodec; +import static com.google.cloud.storage.Utils.topicNameCodec; import com.google.api.pathtemplate.PathTemplate; import com.google.cloud.Binding; @@ -35,6 +36,8 @@ import com.google.cloud.storage.BucketInfo.PublicAccessPrevention; import com.google.cloud.storage.Conversions.Codec; import com.google.cloud.storage.HmacKey.HmacKeyState; +import com.google.cloud.storage.NotificationInfo.EventType; +import com.google.cloud.storage.NotificationInfo.PayloadFormat; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; @@ -48,6 +51,8 @@ import com.google.storage.v2.BucketAccessControl; import com.google.storage.v2.CryptoKeyName; import com.google.storage.v2.HmacKeyMetadata; +import com.google.storage.v2.NotificationConfig; +import com.google.storage.v2.NotificationConfigName; import com.google.storage.v2.Object; import com.google.storage.v2.ObjectAccessControl; import com.google.storage.v2.ObjectChecksums; @@ -918,12 +923,54 @@ private BlobInfo blobInfoDecode(Object from) { return toBuilder.build(); } - private com.google.storage.v2.NotificationConfig notificationEncode(NotificationInfo from) { - return todo(); + private NotificationConfig notificationEncode(NotificationInfo from) { + NotificationConfig.Builder to = NotificationConfig.newBuilder(); + String id = from.getNotificationId(); + if (id != null) { + if (NotificationConfigName.isParsableFrom(id)) { + ifNonNull(id, to::setName); + } else { + NotificationConfigName name = NotificationConfigName.of("_", from.getBucket(), id); + to.setName(name.toString()); + } + } + ifNonNull(from.getTopic(), topicNameCodec::encode, to::setTopic); + ifNonNull(from.getEtag(), to::setEtag); + ifNonNull(from.getEventTypes(), toImmutableListOf(EventType::name), to::addAllEventTypes); + ifNonNull(from.getCustomAttributes(), to::putAllCustomAttributes); + ifNonNull(from.getObjectNamePrefix(), to::setObjectNamePrefix); + ifNonNull(from.getPayloadFormat(), PayloadFormat::name, to::setPayloadFormat); + return to.build(); } - private NotificationInfo notificationDecode(com.google.storage.v2.NotificationConfig from) { - return todo(); + private NotificationInfo notificationDecode(NotificationConfig from) { + NotificationInfo.Builder to = + NotificationInfo.newBuilder(topicNameCodec.decode(from.getTopic())); + if (!from.getName().isEmpty()) { + NotificationConfigName parse = NotificationConfigName.parse(from.getName()); + // the case where parse could return null is already guarded by the preceding isEmpty check + //noinspection DataFlowIssue + to.setNotificationId(parse.getNotificationConfig()); + to.setBucket(parse.getBucket()); + } + if (!from.getEtag().isEmpty()) { + to.setEtag(from.getEtag()); + } + if (!from.getEventTypesList().isEmpty()) { + EventType[] eventTypes = + from.getEventTypesList().stream().map(EventType::valueOf).toArray(EventType[]::new); + to.setEventTypes(eventTypes); + } + if (!from.getCustomAttributesMap().isEmpty()) { + to.setCustomAttributes(from.getCustomAttributesMap()); + } + if (!from.getObjectNamePrefix().isEmpty()) { + to.setObjectNamePrefix(from.getObjectNamePrefix()); + } + if (!from.getPayloadFormat().isEmpty()) { + to.setPayloadFormat(PayloadFormat.valueOf(from.getPayloadFormat())); + } + return to.build(); } private com.google.iam.v1.Policy policyEncode(Policy from) { diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java index dc332b2fd..ce5ebf043 100644 --- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java +++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java @@ -30,7 +30,6 @@ import com.google.api.core.ApiFuture; import com.google.api.core.BetaApi; import com.google.api.gax.grpc.GrpcCallContext; -import com.google.api.gax.grpc.GrpcStatusCode; import com.google.api.gax.paging.AbstractPage; import com.google.api.gax.paging.Page; import com.google.api.gax.retrying.ResultRetryAlgorithm; @@ -39,7 +38,6 @@ import com.google.api.gax.rpc.NotFoundException; import com.google.api.gax.rpc.StatusCode; import com.google.api.gax.rpc.UnaryCallable; -import com.google.api.gax.rpc.UnimplementedException; import com.google.cloud.BaseService; import com.google.cloud.Policy; import com.google.cloud.WriteChannel; @@ -84,18 +82,25 @@ import com.google.storage.v2.ComposeObjectRequest.SourceObject; import com.google.storage.v2.CreateBucketRequest; import com.google.storage.v2.CreateHmacKeyRequest; +import com.google.storage.v2.CreateNotificationConfigRequest; import com.google.storage.v2.DeleteBucketRequest; import com.google.storage.v2.DeleteHmacKeyRequest; +import com.google.storage.v2.DeleteNotificationConfigRequest; import com.google.storage.v2.DeleteObjectRequest; import com.google.storage.v2.GetBucketRequest; import com.google.storage.v2.GetHmacKeyRequest; +import com.google.storage.v2.GetNotificationConfigRequest; import com.google.storage.v2.GetObjectRequest; import com.google.storage.v2.GetServiceAccountRequest; import com.google.storage.v2.ListBucketsRequest; import com.google.storage.v2.ListHmacKeysRequest; +import com.google.storage.v2.ListNotificationConfigsRequest; +import com.google.storage.v2.ListNotificationConfigsResponse; import com.google.storage.v2.ListObjectsRequest; import com.google.storage.v2.ListObjectsResponse; import com.google.storage.v2.LockBucketRetentionPolicyRequest; +import com.google.storage.v2.NotificationConfig; +import com.google.storage.v2.NotificationConfigName; import com.google.storage.v2.Object; import com.google.storage.v2.ObjectAccessControl; import com.google.storage.v2.ObjectChecksums; @@ -103,13 +108,13 @@ import com.google.storage.v2.RewriteObjectRequest; import com.google.storage.v2.RewriteResponse; import com.google.storage.v2.StorageClient; +import com.google.storage.v2.StorageClient.ListNotificationConfigsPage; import com.google.storage.v2.UpdateBucketRequest; import com.google.storage.v2.UpdateHmacKeyRequest; import com.google.storage.v2.UpdateObjectRequest; import com.google.storage.v2.WriteObjectRequest; import com.google.storage.v2.WriteObjectResponse; import com.google.storage.v2.WriteObjectSpec; -import io.grpc.Status.Code; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -1404,23 +1409,92 @@ public ServiceAccount getServiceAccount(String projectId) { @Override public Notification createNotification(String bucket, NotificationInfo notificationInfo) { - return throwNotYetImplemented( - fmtMethodName("createNotification", String.class, NotificationInfo.class)); + NotificationConfig encode = codecs.notificationInfo().encode(notificationInfo); + CreateNotificationConfigRequest req = + CreateNotificationConfigRequest.newBuilder() + .setParent(bucketNameCodec.encode(bucket)) + .setNotificationConfig(encode) + .build(); + return Retrying.run( + getOptions(), + retryAlgorithmManager.getFor(req), + () -> storageClient.createNotificationConfigCallable().call(req), + syntaxDecoders.notificationConfig); } @Override public Notification getNotification(String bucket, String notificationId) { - return throwNotYetImplemented(fmtMethodName("getNotification", String.class, String.class)); + String name; + if (NotificationConfigName.isParsableFrom(notificationId)) { + name = notificationId; + } else { + NotificationConfigName configName = NotificationConfigName.of("_", bucket, notificationId); + name = configName.toString(); + } + GetNotificationConfigRequest req = + GetNotificationConfigRequest.newBuilder().setName(name).build(); + return Retrying.run( + getOptions(), + retryAlgorithmManager.getFor(req), + () -> { + try { + return storageClient.getNotificationConfigCallable().call(req); + } catch (NotFoundException e) { + return null; + } + }, + syntaxDecoders.notificationConfig); } @Override public List listNotifications(String bucket) { - return throwNotYetImplemented(fmtMethodName("listNotifications", String.class)); + ListNotificationConfigsRequest req = + ListNotificationConfigsRequest.newBuilder() + .setParent(bucketNameCodec.encode(bucket)) + .build(); + ResultRetryAlgorithm algorithm = retryAlgorithmManager.getFor(req); + return Retrying.run( + getOptions(), + algorithm, + () -> storageClient.listNotificationConfigsPagedCallable().call(req), + resp -> { + TransformingPageDecorator< + ListNotificationConfigsRequest, + ListNotificationConfigsResponse, + NotificationConfig, + ListNotificationConfigsPage, + Notification> + page = + new TransformingPageDecorator<>( + resp.getPage(), syntaxDecoders.notificationConfig, getOptions(), algorithm); + return ImmutableList.copyOf(page.iterateAll()); + }); } @Override public boolean deleteNotification(String bucket, String notificationId) { - return throwNotYetImplemented(fmtMethodName("deleteNotification", String.class, String.class)); + String name; + if (NotificationConfigName.isParsableFrom(notificationId)) { + name = notificationId; + } else { + NotificationConfigName configName = NotificationConfigName.of("_", bucket, notificationId); + name = configName.toString(); + } + DeleteNotificationConfigRequest req = + DeleteNotificationConfigRequest.newBuilder().setName(name).build(); + return Boolean.TRUE.equals( + Retrying.run( + getOptions(), + retryAlgorithmManager.getFor(req), + () -> { + try { + storageClient.deleteNotificationConfigCallable().call(req); + return true; + } catch (NotFoundException e) { + return false; + } + }, + Decoder.identity())); } @Override @@ -1448,6 +1522,8 @@ private final class SyntaxDecoders { o -> codecs.blobInfo().decode(o).asBlob(GrpcStorageImpl.this); final Decoder bucket = b -> codecs.bucketInfo().decode(b).asBucket(GrpcStorageImpl.this); + final Decoder notificationConfig = + n -> codecs.notificationInfo().decode(n).asNotification(GrpcStorageImpl.this); } /** @@ -1668,14 +1744,6 @@ static T throwHttpJsonOnly(Class clazz, String methodName) { throw new UnsupportedOperationException(message); } - static T throwNotYetImplemented(String methodName) { - String message = - String.format( - "%s#%s is not yet implemented for GRPC transport. Please use StorageOptions.http() to construct a compatible instance in the interim.", - Storage.class.getName(), methodName); - throw new UnimplementedException(message, null, GrpcStatusCode.of(Code.UNIMPLEMENTED), false); - } - private static String fmtMethodName(String name, Class... args) { return name + "(" diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Notification.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Notification.java index 0652620a1..f3e2bb81a 100644 --- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Notification.java +++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Notification.java @@ -88,6 +88,12 @@ public Builder setCustomAttributes(Map customAttributes) { return this; } + @Override + Builder setBucket(String bucket) { + infoBuilder.setBucket(bucket); + return this; + } + @Override public Notification build() { return new Notification(storage, infoBuilder); diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/NotificationInfo.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/NotificationInfo.java index 58621577d..7bf147790 100644 --- a/google-cloud-storage/src/main/java/com/google/cloud/storage/NotificationInfo.java +++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/NotificationInfo.java @@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkNotNull; +import com.google.api.core.InternalApi; import com.google.api.pathtemplate.PathTemplate; import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableMap; @@ -34,11 +35,13 @@ public class NotificationInfo implements Serializable { private static final PathTemplate PATH_TEMPLATE = PathTemplate.createWithoutUrlEncoding("projects/{project}/topics/{topic}"); + // TODO: Change to StringEnum in next major version public enum PayloadFormat { JSON_API_V1, NONE } + // TODO: Change to StringEnum in next major version public enum EventType { OBJECT_FINALIZE, OBJECT_METADATA_UPDATE, @@ -54,6 +57,7 @@ public enum EventType { private final String objectNamePrefix; private final String etag; private final String selfLink; + private final String bucket; /** Builder for {@code NotificationInfo}. */ public abstract static class Builder { @@ -75,6 +79,8 @@ public abstract static class Builder { public abstract Builder setCustomAttributes(Map customAttributes); + abstract Builder setBucket(String bucket); + /** Creates a {@code NotificationInfo} object. */ public abstract NotificationInfo build(); } @@ -90,6 +96,7 @@ public static class BuilderImpl extends Builder { private String objectNamePrefix; private String etag; private String selfLink; + private String bucket; BuilderImpl(String topic) { this.topic = topic; @@ -104,6 +111,7 @@ public static class BuilderImpl extends Builder { customAttributes = notificationInfo.customAttributes; payloadFormat = notificationInfo.payloadFormat; objectNamePrefix = notificationInfo.objectNamePrefix; + bucket = notificationInfo.bucket; } @Override @@ -156,6 +164,12 @@ public Builder setCustomAttributes(Map customAttributes) { return this; } + @Override + Builder setBucket(String bucket) { + this.bucket = bucket; + return this; + } + public NotificationInfo build() { checkNotNull(topic); checkTopicFormat(topic); @@ -172,6 +186,7 @@ public NotificationInfo build() { customAttributes = builder.customAttributes; payloadFormat = builder.payloadFormat; objectNamePrefix = builder.objectNamePrefix; + bucket = builder.bucket; } /** Returns the service-generated id for the notification. */ @@ -225,6 +240,15 @@ public Map getCustomAttributes() { return customAttributes; } + /** + * gRPC has the bucket name encoded in the notification name, use this internal property to track + * it. + */ + @InternalApi + String getBucket() { + return bucket; + } + @Override public int hashCode() { return Objects.hash( diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java index 0cd1ff07b..d91603937 100644 --- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java +++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java @@ -4053,7 +4053,7 @@ List testIamPermissions( * @return the created notification * @throws StorageException upon failure */ - @TransportCompatibility({Transport.HTTP}) + @TransportCompatibility({Transport.HTTP, Transport.GRPC}) Notification createNotification(String bucket, NotificationInfo notificationInfo); /** @@ -4072,7 +4072,7 @@ List testIamPermissions( * @return the {@code Notification} object with the given id or {@code null} if not found * @throws StorageException upon failure */ - @TransportCompatibility({Transport.HTTP}) + @TransportCompatibility({Transport.HTTP, Transport.GRPC}) Notification getNotification(String bucket, String notificationId); /** @@ -4089,7 +4089,7 @@ List testIamPermissions( * @return a list of {@link Notification} objects added to the bucket. * @throws StorageException upon failure */ - @TransportCompatibility({Transport.HTTP}) + @TransportCompatibility({Transport.HTTP, Transport.GRPC}) List listNotifications(String bucket); /** @@ -4113,7 +4113,7 @@ List testIamPermissions( * @return {@code true} if the notification has been deleted, {@code false} if not found * @throws StorageException upon failure */ - @TransportCompatibility({Transport.HTTP}) + @TransportCompatibility({Transport.HTTP, Transport.GRPC}) boolean deleteNotification(String bucket, String notificationId); /** diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Utils.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Utils.java index 0e67c8453..81d7fbb92 100644 --- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Utils.java +++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Utils.java @@ -152,6 +152,26 @@ final class Utils { } }); + private static final String PUBSUB_PREFIX = "//pubsub.googleapis.com/"; + static final Codec topicNameCodec = + Codec.of( + topic -> { + requireNonNull(topic, "topic must be non null"); + if (topic.startsWith(PUBSUB_PREFIX)) { + return topic; + } else { + return PUBSUB_PREFIX + topic; + } + }, + resourceName -> { + requireNonNull(resourceName, "resourceName must be non null"); + if (resourceName.startsWith(PUBSUB_PREFIX)) { + return resourceName.substring(PUBSUB_PREFIX.length()); + } else { + return resourceName; + } + }); + static final Codec crc32cCodec = Codec.of(Utils::crc32cEncode, Utils::crc32cDecode); diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/NotificationInfoPropertyTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/NotificationInfoPropertyTest.java new file mode 100644 index 000000000..5a20dfc60 --- /dev/null +++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/NotificationInfoPropertyTest.java @@ -0,0 +1,23 @@ +/* + * Copyright 2023 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 + * + * http://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. + */ + +package com.google.cloud.storage; + +import com.google.storage.v2.NotificationConfig; + +final class NotificationInfoPropertyTest + extends BaseConvertablePropertyTest< + NotificationInfo, NotificationConfig, com.google.api.services.storage.model.Notification> {} diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITNotificationTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITNotificationTest.java index 9aa528c1a..69a066580 100644 --- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITNotificationTest.java +++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITNotificationTest.java @@ -16,18 +16,21 @@ package com.google.cloud.storage.it; +import static com.google.cloud.storage.TestUtils.assertAll; import static com.google.common.truth.Truth.assertThat; import com.google.cloud.pubsub.v1.TopicAdminClient; import com.google.cloud.storage.BucketInfo; import com.google.cloud.storage.Notification; import com.google.cloud.storage.NotificationInfo; +import com.google.cloud.storage.NotificationInfo.PayloadFormat; import com.google.cloud.storage.Storage; import com.google.cloud.storage.TransportCompatibility.Transport; import com.google.cloud.storage.it.runner.StorageITRunner; import com.google.cloud.storage.it.runner.annotations.Backend; import com.google.cloud.storage.it.runner.annotations.CrossRun; import com.google.cloud.storage.it.runner.annotations.Inject; +import com.google.cloud.storage.it.runner.registry.Generator; import com.google.common.collect.ImmutableMap; import com.google.iam.v1.Binding; import com.google.iam.v1.GetIamPolicyRequest; @@ -35,7 +38,6 @@ import java.io.IOException; import java.util.List; import java.util.Map; -import java.util.UUID; import java.util.logging.Level; import java.util.logging.Logger; import org.junit.After; @@ -45,44 +47,58 @@ @RunWith(StorageITRunner.class) @CrossRun( - transports = {Transport.HTTP}, - backends = {Backend.PROD}) + transports = {Transport.HTTP, Transport.GRPC}, + backends = {Backend.PROD, Backend.TEST_BENCH}) public class ITNotificationTest { - private static final Notification.PayloadFormat PAYLOAD_FORMAT = - Notification.PayloadFormat.JSON_API_V1.JSON_API_V1; + private static final Notification.PayloadFormat PAYLOAD_FORMAT = PayloadFormat.JSON_API_V1; private static final Map CUSTOM_ATTRIBUTES = ImmutableMap.of("label1", "value1"); private static final Logger log = Logger.getLogger(ITNotificationTest.class.getName()); + private static final String DOES_NOT_EXIST_ID = "something-that-does-not-exist-probably"; + @Inject public Backend backend; + @Inject public Transport transport; @Inject public Storage storage; @Inject public BucketInfo bucket; + @Inject public Generator generator; private TopicAdminClient topicAdminClient; private String topic; + private NotificationInfo notificationInfo; @Before public void setup() throws IOException { - // Configure topic admin client for notification. - topicAdminClient = TopicAdminClient.create(); String projectId = storage.getOptions().getProjectId(); - String id = UUID.randomUUID().toString().substring(0, 8); - topic = String.format("projects/%s/topics/test_topic_foo_%s", projectId, id).trim(); + // square brackets are not acceptable characters for topic names, replace them with dash + // https://cloud.google.com/pubsub/docs/admin#resource_names + String name = generator.randomObjectName().replaceAll("[\\[\\]]", "-"); + topic = String.format("projects/%s/topics/%s", projectId, name).trim(); + notificationInfo = + NotificationInfo.newBuilder(topic) + .setCustomAttributes(CUSTOM_ATTRIBUTES) + .setPayloadFormat(PAYLOAD_FORMAT) + .build(); - topicAdminClient.createTopic(this.topic); + if (backend == Backend.PROD && transport == Transport.HTTP) { - GetIamPolicyRequest getIamPolicyRequest = - GetIamPolicyRequest.newBuilder().setResource(this.topic).build(); + // Configure topic admin client for notification. + topicAdminClient = TopicAdminClient.create(); + topicAdminClient.createTopic(this.topic); - com.google.iam.v1.Policy policy = topicAdminClient.getIamPolicy(getIamPolicyRequest); + GetIamPolicyRequest getIamPolicyRequest = + GetIamPolicyRequest.newBuilder().setResource(this.topic).build(); - Binding binding = - Binding.newBuilder().setRole("roles/owner").addMembers("allAuthenticatedUsers").build(); + com.google.iam.v1.Policy policy = topicAdminClient.getIamPolicy(getIamPolicyRequest); - SetIamPolicyRequest setIamPolicyRequest = - SetIamPolicyRequest.newBuilder() - .setResource(this.topic) - .setPolicy(policy.toBuilder().addBindings(binding).build()) - .build(); - topicAdminClient.setIamPolicy(setIamPolicyRequest); + Binding binding = + Binding.newBuilder().setRole("roles/owner").addMembers("allAuthenticatedUsers").build(); + + SetIamPolicyRequest setIamPolicyRequest = + SetIamPolicyRequest.newBuilder() + .setResource(this.topic) + .setPolicy(policy.toBuilder().addBindings(binding).build()) + .build(); + topicAdminClient.setIamPolicy(setIamPolicyRequest); + } } @After @@ -100,49 +116,80 @@ public void cleanup() { } @Test - public void testNotification() { - NotificationInfo notificationInfo = - NotificationInfo.newBuilder(topic) - .setCustomAttributes(CUSTOM_ATTRIBUTES) - .setPayloadFormat(PAYLOAD_FORMAT) - .build(); - try { - assertThat(storage.listNotifications(bucket.getName())).isEmpty(); - Notification notification = storage.createNotification(bucket.getName(), notificationInfo); - assertThat(notification.getNotificationId()).isNotNull(); - assertThat(CUSTOM_ATTRIBUTES).isEqualTo(notification.getCustomAttributes()); - assertThat(PAYLOAD_FORMAT.name()).isEqualTo(notification.getPayloadFormat().name()); - assertThat(notification.getTopic().contains(topic)).isTrue(); - - // Gets the notification with the specified id. - Notification actualNotification = - storage.getNotification(bucket.getName(), notification.getNotificationId()); - assertThat(actualNotification.getNotificationId()) - .isEqualTo(notification.getNotificationId()); - assertThat(actualNotification.getTopic().trim()).isEqualTo(notification.getTopic().trim()); - assertThat(actualNotification.getEtag()).isEqualTo(notification.getEtag()); - assertThat(actualNotification.getEventTypes()).isEqualTo(notification.getEventTypes()); - assertThat(actualNotification.getPayloadFormat()).isEqualTo(notification.getPayloadFormat()); - assertThat(actualNotification.getSelfLink()).isEqualTo(notification.getSelfLink()); - assertThat(actualNotification.getCustomAttributes()) - .isEqualTo(notification.getCustomAttributes()); - - // Retrieves the list of notifications associated with the bucket. - List notifications = storage.listNotifications(bucket.getName()); - assertThat(notifications.size()).isEqualTo(1); - assertThat(notifications.get(0).getNotificationId()) - .isEqualTo(actualNotification.getNotificationId()); - - // Deletes the notification with the specified id. - assertThat(storage.deleteNotification(bucket.getName(), notification.getNotificationId())) - .isTrue(); - assertThat(storage.deleteNotification(bucket.getName(), notification.getNotificationId())) - .isFalse(); - assertThat(storage.getNotification(bucket.getName(), notification.getNotificationId())) - .isNull(); - assertThat(storage.listNotifications(bucket.getName())).isEmpty(); - } finally { - // delete is taken care of by BucketFixture now + @CrossRun.Ignore(transports = Transport.GRPC, backends = Backend.PROD) + public void listNotification_doesNotExist() throws Exception { + // create a temporary bucket to ensure we're immune from ordering on other tests + try (TemporaryBucket tempB = + TemporaryBucket.newBuilder() + .setBucketInfo(BucketInfo.of(generator.randomBucketName())) + .setStorage(storage) + .build()) { + List notifications = storage.listNotifications(tempB.getBucket().getName()); + assertThat(notifications).isEmpty(); } } + + @Test + @CrossRun.Ignore(transports = Transport.GRPC, backends = Backend.PROD) + public void listNotification_exists() { + Notification notification = storage.createNotification(bucket.getName(), notificationInfo); + List notifications = storage.listNotifications(bucket.getName()); + assertThat(notifications).isNotEmpty(); + assertThat(notifications).contains(notification); + } + + @Test + @CrossRun.Ignore(transports = Transport.GRPC, backends = Backend.PROD) + public void createNotification_doesNotExist() throws Exception { + Notification notification = storage.createNotification(bucket.getName(), notificationInfo); + assertAll( + () -> assertThat(notification.getNotificationId()).isNotNull(), + () -> assertThat(notification.getCustomAttributes()).isEqualTo(CUSTOM_ATTRIBUTES), + () -> assertThat(notification.getPayloadFormat()).isEqualTo(PAYLOAD_FORMAT), + () -> assertThat(notification.getTopic()).contains(topic)); + } + + @Test + @CrossRun.Ignore(transports = Transport.GRPC, backends = Backend.PROD) + public void getNotification_exists() throws Exception { + Notification notification = storage.createNotification(bucket.getName(), notificationInfo); + + Notification getResult = + storage.getNotification(bucket.getName(), notification.getNotificationId()); + + assertAll( + () -> assertThat(getResult.getNotificationId()).isEqualTo(notification.getNotificationId()), + () -> assertThat(getResult.getTopic()).isEqualTo(notification.getTopic()), + () -> assertThat(getResult.getEtag()).isEqualTo(notification.getEtag()), + () -> assertThat(getResult.getEventTypes()).isEqualTo(notification.getEventTypes()), + () -> assertThat(getResult.getPayloadFormat()).isEqualTo(notification.getPayloadFormat()), + () -> + assertThat(getResult.getCustomAttributes()) + .isEqualTo(notification.getCustomAttributes()), + () -> assertThat(getResult).isEqualTo(notification)); + } + + @Test + @CrossRun.Ignore(transports = Transport.GRPC, backends = Backend.PROD) + public void getNotification_doesNotExists() { + Notification getResult = storage.getNotification(bucket.getName(), DOES_NOT_EXIST_ID); + + assertThat(getResult).isNull(); + } + + @Test + @CrossRun.Ignore(transports = Transport.GRPC, backends = Backend.PROD) + public void deleteNotification_exists() { + Notification notification = storage.createNotification(bucket.getName(), notificationInfo); + boolean deleteResult = + storage.deleteNotification(bucket.getName(), notification.getNotificationId()); + assertThat(deleteResult).isTrue(); + } + + @Test + @CrossRun.Ignore(transports = Transport.GRPC, backends = Backend.PROD) + public void deleteNotification_doesNotExists() { + boolean deleteResult = storage.deleteNotification(bucket.getName(), DOES_NOT_EXIST_ID); + assertThat(deleteResult).isFalse(); + } } diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/NotificationArbitraryProvider.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/NotificationArbitraryProvider.java new file mode 100644 index 000000000..8e960791c --- /dev/null +++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/NotificationArbitraryProvider.java @@ -0,0 +1,94 @@ +/* + * Copyright 2023 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 + * + * http://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. + */ + +package com.google.cloud.storage.jqwik; + +import static com.google.cloud.storage.PackagePrivateMethodWorkarounds.ifNonNull; + +import com.google.pubsub.v1.TopicName; +import com.google.storage.v2.NotificationConfig; +import com.google.storage.v2.NotificationConfigName; +import java.util.Collections; +import java.util.Set; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.Combinators; +import net.jqwik.api.providers.ArbitraryProvider; +import net.jqwik.api.providers.TypeUsage; +import org.checkerframework.checker.nullness.qual.NonNull; + +public final class NotificationArbitraryProvider implements ArbitraryProvider { + + @Override + public boolean canProvideFor(TypeUsage targetType) { + return targetType.isOfType(NotificationConfig.class); + } + + @NonNull + @Override + public Set> provideFor( + @NonNull TypeUsage targetType, @NonNull SubtypeProvider subtypeProvider) { + Arbitrary as = + Combinators.combine( + notificationName(), + topic(), + StorageArbitraries.etag().injectNull(0.5), + eventTypes(), + StorageArbitraries.buckets().labels(), + StorageArbitraries.objects().name().injectNull(0.5), + Arbitraries.of("JSON_API_V1", "NONE")) + .as( + (name, topic, etag, types, customAttributes, prefix, payloadFormat) -> { + NotificationConfig.Builder b = + NotificationConfig.newBuilder() + .setName(name.toString()) + .setTopic(topic) + .setPayloadFormat(payloadFormat); + ifNonNull(types, b::addAllEventTypes); + ifNonNull(customAttributes, b::putAllCustomAttributes); + ifNonNull(etag, b::setEtag); + ifNonNull(prefix, b::setObjectNamePrefix); + return b.build(); + }); + return Collections.singleton(as); + } + + private static Arbitrary> eventTypes() { + return Arbitraries.of( + "OBJECT_FINALIZE", "OBJECT_METADATA_UPDATE", "OBJECT_DELETE", "OBJECT_ARCHIVE") + .set() + .ofMinSize(0) + .ofMaxSize(4); + } + + @NonNull + private static Arbitrary topic() { + return Combinators.combine( + StorageArbitraries.projectID(), + StorageArbitraries.alphaString().ofMinLength(1).ofMaxLength(10)) + .as((p, t) -> TopicName.of(p.get(), t)) + .map(tn -> "//pubsub.googleapis.com/" + tn.toString()); + } + + @NonNull + private static Arbitrary notificationName() { + return Combinators.combine( + StorageArbitraries.buckets().name(), StorageArbitraries.alphaString().ofMinLength(1)) + .as( + (bucket, notification) -> + NotificationConfigName.of(bucket.getProject(), bucket.getBucket(), notification)); + } +} diff --git a/google-cloud-storage/src/test/resources/META-INF/services/net.jqwik.api.providers.ArbitraryProvider b/google-cloud-storage/src/test/resources/META-INF/services/net.jqwik.api.providers.ArbitraryProvider index 2014e4acf..2fcbe230f 100644 --- a/google-cloud-storage/src/test/resources/META-INF/services/net.jqwik.api.providers.ArbitraryProvider +++ b/google-cloud-storage/src/test/resources/META-INF/services/net.jqwik.api.providers.ArbitraryProvider @@ -19,3 +19,4 @@ com.google.cloud.storage.jqwik.BucketArbitraryProvider com.google.cloud.storage.jqwik.HmacKeyMetadataArbitraryProvider com.google.cloud.storage.jqwik.ServiceAccountArbitraryProvider com.google.cloud.storage.jqwik.IamPolicyArbitraryProvider +com.google.cloud.storage.jqwik.NotificationArbitraryProvider