Repository: googleapis/java-storage
Pull request #1513 (2022-07-18 18:33:50)

test: introduce BaseConvertablePropertyTest#codecCompatibilityRoundTrip
### test: introduce BaseConvertablePropertyTest#codecCompatibilityRoundTrip
Introduce new codecCompatibilityRoundTrip test which aims to ensure compatibility between codecs for a ModelT for both gRPC and Apiary.
Because our Model classes are abstracting both gRPC and Apiary transport models we want to ensure our conversions are handling things accurately for each transport.
In this case, we can potentially see a real life use case where our model class might be serialized when it was retrieved via one transport before being sent back via different transport.

#### Refactorings
1. Update BaseConvertablePropertyTest to also include the Apiary type which underpins a particular ModelT.
2. Update BaseConvertablePropertyTest to automatically resolve codec instances, rather than requiring an overridden method. (Method can still be overridden if necessary)
3. Update each subclass of BaseConvertablePropertyTest to specify the new Apiary type parameters, and remove the grpc codec method overrides.

### chore: update nanosecond value generation to be constrained to millisecond range

gRPC has nanosecond level precision for timestamps, whereas JSON is limited to millisecond
precision due to `com.google.api.client.util.DateTime` silently dropping anything below milliseconds.

Define an arbitrary, which will always produce a nanosecond value that is in the range of
0-999 milliseconds.

### chore: limit bucket().name() project ids to `_`

We don't yet support non-global scoped buckets
 
### fix: preserve original order of lifecycle rules

We no longer need the set, because we don't have duplication entries split across lifecycleRules and deleteRules

### chore: update behavior of bucket name handling

In order to allow proper cross transport compatibility for bucket operations, we need to ensure no bucket related information is lost when crossing the respective encoding/decoding boundaries.

As of today, project scoped buckets are not implemented by the backend service. Additionally, there is not yet any indication of how project scoped buckets will be surfaced in the json/xml apis. This change updates our handling of bucket names to ONLY track the bucket name, never the project segment. All grpc related operations will prepend/remove the `projects/_/bucket/` as necessary.

The BucketInfoPropertyTests now pass for the codecCompatibilityRoundTrip test.

Partial revert of https://github.com/googleapis/java-storage/pull/1454


Patch:
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 593f74e9..b330fad9 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
@@ -50,7 +50,6 @@ import com.google.cloud.storage.Acl.RawEntity;
 import com.google.cloud.storage.Acl.Role;
 import com.google.cloud.storage.Acl.User;
 import com.google.cloud.storage.BlobInfo.CustomerEncryption;
-import com.google.cloud.storage.BucketInfo.BuilderImpl;
 import com.google.cloud.storage.BucketInfo.CustomPlacementConfig;
 import com.google.cloud.storage.BucketInfo.IamConfiguration;
 import com.google.cloud.storage.BucketInfo.LifecycleRule;
@@ -74,10 +73,8 @@ import java.time.Duration;
 import java.time.OffsetDateTime;
 import java.util.ArrayList;
 import java.util.Collections;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import java.util.function.Function;
 
 @InternalApi
@@ -346,7 +343,7 @@ final class ApiaryConversions {
       if (lifecycleRules.isEmpty()) {
         lifecycle.setRule(Collections.emptyList());
       } else {
-        Set<Rule> rules = new HashSet<>();
+        List<Rule> rules = new ArrayList<>();
         ifNonNull(lifecycleRules, r -> r.stream().map(lifecycleRule()::encode).forEach(rules::add));
         if (!rules.isEmpty()) {
           lifecycle.setRule(ImmutableList.copyOf(rules));
@@ -386,7 +383,7 @@ final class ApiaryConversions {
 
   @SuppressWarnings("deprecation")
   private BucketInfo bucketInfoDecode(com.google.api.services.storage.model.Bucket from) {
-    BucketInfo.Builder to = new BuilderImpl(from.getName());
+    BucketInfo.Builder to = new BucketInfo.BuilderImpl(from.getName());
     ifNonNull(from.getAcl(), toImmutableListOf(bucketAcl()::decode), to::setAcl);
     ifNonNull(from.getCors(), toImmutableListOf(cors()::decode), to::setCors);
     ifNonNull(
@@ -579,8 +576,8 @@ final class ApiaryConversions {
     Bucket.Logging to;
     if (from.getLogBucket() != null || from.getLogObjectPrefix() != null) {
       to = new Bucket.Logging();
-      to.setLogBucket(from.getLogBucket());
-      to.setLogObjectPrefix(from.getLogObjectPrefix());
+      ifNonNull(from.getLogBucket(), to::setLogBucket);
+      ifNonNull(from.getLogObjectPrefix(), to::setLogObjectPrefix);
     } else {
       to = Data.nullOf(Bucket.Logging.class);
     }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java
index 971c0d30..0f0c91ca 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java
@@ -71,7 +71,8 @@ public class BucketInfo implements Serializable {
 
   private static final long serialVersionUID = -4712013629621638459L;
   private final String generatedId;
-  private final BucketWithProject bucketWithProject;
+  private final String project;
+  private final String name;
   private final Acl.Entity owner;
   private final String selfLink;
   private final Boolean requesterPays;
@@ -392,7 +393,7 @@ public class BucketInfo implements Serializable {
   public static class Logging implements Serializable {
 
     private static final long serialVersionUID = -708892101216778492L;
-    private final BucketWithProject logBucketWithProject;
+    private final String logBucket;
     private final String logObjectPrefix;
 
     @Override
@@ -404,13 +405,13 @@ public class BucketInfo implements Serializable {
         return false;
       }
       Logging logging = (Logging) o;
-      return Objects.equals(logBucketWithProject, logging.logBucketWithProject)
+      return Objects.equals(logBucket, logging.logBucket)
           && Objects.equals(logObjectPrefix, logging.logObjectPrefix);
     }
 
     @Override
     public int hashCode() {
-      return Objects.hash(logBucketWithProject, logObjectPrefix);
+      return Objects.hash(logBucket, logObjectPrefix);
     }
 
     public static Builder newBuilder() {
@@ -419,17 +420,13 @@ public class BucketInfo implements Serializable {
 
     public Builder toBuilder() {
       Builder builder = new Builder();
-      builder.logBucketWithProject = new BucketWithProject(logBucketWithProject);
+      builder.logBucket = logBucket;
       builder.logObjectPrefix = logObjectPrefix;
       return builder;
     }
 
-    String getLogBucketProject() {
-      return logBucketWithProject.getProject();
-    }
-
     public String getLogBucket() {
-      return logBucketWithProject.getBucketName();
+      return logBucket;
     }
 
     public String getLogObjectPrefix() {
@@ -437,26 +434,17 @@ public class BucketInfo implements Serializable {
     }
 
     private Logging(Builder builder) {
-      this.logBucketWithProject = builder.logBucketWithProject;
+      this.logBucket = builder.logBucket;
       this.logObjectPrefix = builder.logObjectPrefix;
     }
 
     public static class Builder {
-      private BucketWithProject logBucketWithProject;
+      private String logBucket;
       private String logObjectPrefix;
 
-      public Builder() {
-        logBucketWithProject = new BucketWithProject();
-      }
-
-      Builder setLogBucketProject(String project) {
-        this.logBucketWithProject.setProject(project);
-        return this;
-      }
-
       /** The destination bucket where the current bucket's logs should be placed. */
       public Builder setLogBucket(String logBucket) {
-        this.logBucketWithProject.setBucketName(logBucket);
+        this.logBucket = logBucket;
         return this;
       }
 
@@ -1444,7 +1432,8 @@ public class BucketInfo implements Serializable {
   static final class BuilderImpl extends Builder {
 
     private String generatedId;
-    private BucketWithProject bucketWithProject;
+    private String project;
+    private String name;
     private Acl.Entity owner;
     private String selfLink;
     private Boolean requesterPays;
@@ -1474,12 +1463,13 @@ public class BucketInfo implements Serializable {
     private CustomPlacementConfig customPlacementConfig;
 
     BuilderImpl(String name) {
-      this.bucketWithProject = new BucketWithProject(name);
+      this.name = name;
     }
 
     BuilderImpl(BucketInfo bucketInfo) {
       generatedId = bucketInfo.generatedId;
-      bucketWithProject = bucketInfo.bucketWithProject;
+      project = bucketInfo.project;
+      name = bucketInfo.name;
       etag = bucketInfo.etag;
       createTime = bucketInfo.createTime;
       updateTime = bucketInfo.updateTime;
@@ -1511,13 +1501,13 @@ public class BucketInfo implements Serializable {
 
     @Override
     public Builder setName(String name) {
-      this.bucketWithProject.setBucketName(checkNotNull(name));
+      this.name = checkNotNull(name);
       return this;
     }
 
     @Override
     Builder setProject(String project) {
-      this.bucketWithProject.setProject(project);
+      this.project = project;
       return this;
     }
 
@@ -1789,7 +1779,7 @@ public class BucketInfo implements Serializable {
 
     @Override
     public BucketInfo build() {
-      checkNotNull(bucketWithProject.getBucketName());
+      checkNotNull(name);
       return new BucketInfo(this);
     }
 
@@ -1808,7 +1798,8 @@ public class BucketInfo implements Serializable {
 
   BucketInfo(BuilderImpl builder) {
     generatedId = builder.generatedId;
-    bucketWithProject = builder.bucketWithProject;
+    project = builder.project;
+    name = builder.name;
     etag = builder.etag;
     createTime = builder.createTime;
     updateTime = builder.updateTime;
@@ -1839,7 +1830,7 @@ public class BucketInfo implements Serializable {
   }
 
   String getProject() {
-    return bucketWithProject.getProject();
+    return project;
   }
 
   /** Returns the service-generated id for the bucket. */
@@ -1849,7 +1840,7 @@ public class BucketInfo implements Serializable {
 
   /** Returns the bucket's name. */
   public String getName() {
-    return bucketWithProject.getBucketName();
+    return name;
   }
 
   /** Returns the bucket's owner. This is always the project team's owner group. */
@@ -2173,7 +2164,7 @@ public class BucketInfo implements Serializable {
   public int hashCode() {
     return Objects.hash(
         generatedId,
-        bucketWithProject,
+        name,
         owner,
         selfLink,
         requesterPays,
@@ -2212,7 +2203,7 @@ public class BucketInfo implements Serializable {
     }
     BucketInfo that = (BucketInfo) o;
     return Objects.equals(generatedId, that.generatedId)
-        && Objects.equals(bucketWithProject, that.bucketWithProject)
+        && Objects.equals(name, that.name)
         && Objects.equals(owner, that.owner)
         && Objects.equals(selfLink, that.selfLink)
         && Objects.equals(requesterPays, that.requesterPays)
@@ -2243,9 +2234,7 @@ public class BucketInfo implements Serializable {
 
   @Override
   public String toString() {
-    return MoreObjects.toStringHelper(this)
-        .add("name", bucketWithProject.getBucketName())
-        .toString();
+    return MoreObjects.toStringHelper(this).add("name", name).toString();
   }
 
   /**
@@ -2265,52 +2254,4 @@ public class BucketInfo implements Serializable {
   public static Builder newBuilder(String name) {
     return new BuilderImpl(name);
   }
-
-  private static class BucketWithProject implements Serializable {
-    private static final long serialVersionUID = 3620131016523045533L;
-    private String project = "_";
-    private String bucketName;
-
-    BucketWithProject() {}
-
-    BucketWithProject(String bucketName) {
-      this.bucketName = bucketName;
-    }
-
-    BucketWithProject(BucketWithProject bucketWithProject) {
-      this.project = bucketWithProject.project;
-      this.bucketName = bucketWithProject.bucketName;
-    }
-
-    void setProject(String project) {
-      this.project = project;
-    }
-
-    void setBucketName(String bucketName) {
-      this.bucketName = bucketName;
-    }
-
-    String getProject() {
-      return this.project;
-    }
-
-    String getBucketName() {
-      return this.bucketName;
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      if (this == o) return true;
-      if (o == null || getClass() != o.getClass()) {
-        return false;
-      }
-      BucketWithProject other = (BucketWithProject) o;
-      return other.project.equals(this.project) && other.bucketName.equals(this.bucketName);
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hash(project, bucketName);
-    }
-  }
 }
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 17517c7d..37987134 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
@@ -16,6 +16,7 @@
 
 package com.google.cloud.storage;
 
+import static com.google.cloud.storage.Utils.bucketNameCodec;
 import static com.google.cloud.storage.Utils.durationMillisCodec;
 import static com.google.cloud.storage.Utils.ifNonNull;
 import static com.google.cloud.storage.Utils.lift;
@@ -36,7 +37,6 @@ import com.google.protobuf.Descriptors;
 import com.google.protobuf.Timestamp;
 import com.google.storage.v2.Bucket;
 import com.google.storage.v2.Bucket.Billing;
-import com.google.storage.v2.BucketName;
 import com.google.storage.v2.HmacKeyMetadata;
 import com.google.storage.v2.Object;
 import com.google.storage.v2.ObjectAccessControl;
@@ -181,13 +181,7 @@ final class GrpcConversions {
   }
 
   private BucketInfo bucketInfoDecode(Bucket from) {
-    BucketName bucketName = BucketName.parse(from.getName());
-    // `name` above is a read-only value from gcs, which enforces non-emptiness.
-    //   BucketName.parse will return null of name is empty. Since name can't be empty
-    //   we don't need to explicitly check for null, as the only other failure condition
-    //   would be a parse exception.
-    //noinspection ConstantConditions
-    BucketInfo.Builder to = BucketInfo.newBuilder(bucketName.getBucket());
+    BucketInfo.Builder to = new BucketInfo.BuilderImpl(bucketNameCodec.decode(from.getName()));
     to.setProject(from.getProject());
     to.setGeneratedId(from.getBucketId());
     if (from.hasRetentionPolicy()) {
@@ -268,8 +262,7 @@ final class GrpcConversions {
 
   private Bucket bucketInfoEncode(BucketInfo from) {
     Bucket.Builder to = Bucket.newBuilder();
-    to.setProject(from.getProject());
-    to.setName(BucketName.format(from.getProject(), from.getName()));
+    to.setName(bucketNameCodec.encode(from.getName()));
     // TODO: We need to clean up bucketId handling
     ifNonNull(from.getGeneratedId(), to::setBucketId);
     if (from.getRetentionPeriodDuration() != null) {
@@ -355,35 +348,24 @@ final class GrpcConversions {
 
   private Bucket.Logging loggingEncode(BucketInfo.Logging from) {
     Bucket.Logging.Builder to = Bucket.Logging.newBuilder();
-    if (from.getLogObjectPrefix() != null) {
+    if (!from.getLogObjectPrefix().isEmpty()) {
       to.setLogObjectPrefix(from.getLogObjectPrefix());
     }
-    if (from.getLogBucket() != null) {
-      // TODO: Remove unformatted bucket name support when logging bucket only supports formatted
-      // "projects/project/buckets/bucket"
-      if (from.getLogBucketProject().isEmpty()) {
-        to.setLogBucket(from.getLogBucket());
-      } else {
-        to.setLogBucket(BucketName.format(from.getLogBucketProject(), from.getLogBucket()));
-      }
-    }
+    ifNonNull(from.getLogBucket(), bucketNameCodec::encode, to::setLogBucket);
     return to.build();
   }
 
   private BucketInfo.Logging loggingDecode(Bucket.Logging from) {
-    BucketInfo.Logging.Builder loggingBuilder =
-        BucketInfo.Logging.newBuilder().setLogObjectPrefix(from.getLogObjectPrefix());
-    if (BucketName.isParsableFrom(from.getLogBucket())) {
-      BucketName bucketName = BucketName.parse(from.getLogBucket());
-      loggingBuilder.setLogBucket(bucketName.getBucket());
-      loggingBuilder.setLogBucketProject(bucketName.getProject());
-    } else {
-      // TODO: Remove unformatted bucket name support when logging bucket only supports formatted
-      // "projects/project/buckets/bucket"
-      loggingBuilder.setLogBucket(from.getLogBucket());
-      loggingBuilder.setLogBucketProject("");
+    BucketInfo.Logging.Builder to = BucketInfo.Logging.newBuilder();
+    String logObjectPrefix = from.getLogObjectPrefix();
+    if (!logObjectPrefix.isEmpty()) {
+      to.setLogObjectPrefix(logObjectPrefix);
+    }
+    String logBucket = from.getLogBucket();
+    if (!logBucket.isEmpty()) {
+      to.setLogBucket(bucketNameCodec.decode(logBucket));
     }
-    return loggingBuilder.build();
+    return to.build();
   }
 
   private Bucket.Cors corsEncode(Cors from) {
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 d642f775..4ae825b0 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
@@ -16,11 +16,14 @@
 
 package com.google.cloud.storage;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.api.client.util.DateTime;
 import com.google.api.core.InternalApi;
 import com.google.cloud.storage.Conversions.Codec;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
+import com.google.storage.v2.BucketName;
 import java.time.Duration;
 import java.time.Instant;
 import java.time.OffsetDateTime;
@@ -72,6 +75,51 @@ final class Utils {
 
   static final Codec<OffsetDateTime, DateTime> nullableDateTimeCodec = dateTimeCodec.nullable();
 
+  /**
+   * Define a Codec which encapsulates the logic necessary to handle encoding and decoding bucket
+   * names.
+   *
+   * <p>The "Model Type" in this case is the raw bucket name as would be read from {@link
+   * BucketInfo#getName()}. The "Proto Type" in this case is the OnePlatform formatted
+   * representation of the bucket name.
+   *
+   * <p>As of the time of writing this, project scoped buckets are not implemented by the backend
+   * service. While we need to be cognisant that they are on the horizon, we do not need to track
+   * any data related to this future feature. As such, this method attempts to make it easier to
+   * work with bucket names that require the OnePlatform format while still preventing any subtle
+   * bugs happening to customers if they happen to attempt to use project scoped bucket features in
+   * this library once the service does support it.
+   *
+   * <p>TODO: this will need to change once the project scoped buckets first class feature work is
+   * done.
+   */
+  static final Codec<String, String> bucketNameCodec =
+      Codec.of(
+          bucket -> {
+            requireNonNull(bucket, "bucket must be non null");
+            if (bucket.startsWith("projects/")) {
+              if (bucket.startsWith("projects/_")) {
+                return bucket;
+              } else {
+                throw new IllegalArgumentException(
+                    "Project scoped buckets are not supported by this version of the library. (bucket = "
+                        + bucket
+                        + ")");
+              }
+            } else {
+              return "projects/_/buckets/" + bucket;
+            }
+          },
+          resourceName -> {
+            requireNonNull(resourceName, "resourceName must be non null");
+            if (BucketName.isParsableFrom(resourceName)) {
+              BucketName parse = BucketName.parse(resourceName);
+              return parse.getBucket();
+            } else {
+              return resourceName;
+            }
+          });
+
   private Utils() {}
 
   /**


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/BaseConvertablePropertyTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/BaseConvertablePropertyTest.java
index 262ad555..2435aa40 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/BaseConvertablePropertyTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/BaseConvertablePropertyTest.java
@@ -18,19 +18,46 @@ package com.google.cloud.storage;
 
 import static com.google.cloud.storage.JqwikTest.report;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
+import com.google.api.client.json.GenericJson;
 import com.google.cloud.storage.Conversions.Codec;
 import com.google.protobuf.Message;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Arrays;
 import java.util.Optional;
 import net.jqwik.api.Example;
 import net.jqwik.api.ForAll;
 import net.jqwik.api.Property;
 import net.jqwik.api.providers.TypeUsage;
+import org.checkerframework.checker.nullness.qual.Nullable;
 
-abstract class BaseConvertablePropertyTest<ModelT, ProtoT extends Message> {
+abstract class BaseConvertablePropertyTest<
+    ModelT, ProtoT extends Message, ApiaryT extends GenericJson> {
+
+  private final TypeUsage modelTType;
+  private final TypeUsage protoTType;
+  private final TypeUsage apiaryTType;
+
+  BaseConvertablePropertyTest() {
+    TypeUsage baseTypeUsage = findBaseTypeUsage(this.getClass());
+    modelTType = baseTypeUsage.getTypeArgument(0);
+    protoTType = baseTypeUsage.getTypeArgument(1);
+    apiaryTType = baseTypeUsage.getTypeArgument(2);
+  }
+
+  /** Provide the codec instance used to convert between {@code ModelT} and {@code ProtoT} */
+  Codec<ModelT, ProtoT> grpcCodec() {
+    GrpcConversions instance = Conversions.grpc();
+    return resolveCodec(instance, protoTType);
+  }
 
   /** Provide the codec instance used to convert between {@code ModelT} and {@code ProtoT} */
-  abstract Codec<ModelT, ProtoT> codec();
+  Codec<ModelT, ApiaryT> apiaryCodec() {
+    ApiaryConversions instance = Conversions.apiary();
+    return resolveCodec(instance, apiaryTType);
+  }
 
   /** Report on detected edge cases for {@code ProtoT} */
   @Example
@@ -43,20 +70,42 @@ abstract class BaseConvertablePropertyTest<ModelT, ProtoT extends Message> {
   }
 
   /**
-   * Ensure that {@code @ForAll ProtoT} the codec provided by {@link #codec} can round trip each
+   * Ensure that {@code @ForAll ProtoT} the codec provided by {@link #grpcCodec} can round trip each
    * {@code ProtoT} such that the provided value is equal to the round tripped value.
    *
    * <p>Note: round trip means A -> B -> A, in this case ProtoT -> ModelT -> ProtoT
    */
   @Property
   final void codecRoundTrip(@ForAll ProtoT p) {
-    Codec<ModelT, ProtoT> codec = codec();
+    Codec<ModelT, ProtoT> codec = grpcCodec();
     ModelT model = codec.decode(p);
     ProtoT proto = codec.encode(model);
 
     assertThat(proto).isEqualTo(p);
   }
 
+  /**
+   * Ensure that {@code @ForAll ProtoT} the conversion chain ProtoT -> ModelT -> ApiaryT -> ModelT
+   * -> ProtoT results in an equal value.
+   *
+   * <p>This test is intended to ensue compatibility of our conversions across both gRPC and apiary
+   * models.
+   */
+  @Property
+  final void codecCompatibilityRoundTrip(@ForAll ProtoT p) {
+    Codec<ModelT, ProtoT> codecG = grpcCodec();
+    Codec<ModelT, ApiaryT> codecA = apiaryCodec();
+
+    ModelT model = codecG.decode(p);
+
+    ApiaryT apiary = codecA.encode(model);
+    ModelT model2 = codecA.decode(apiary);
+
+    ProtoT actual = codecG.encode(model2);
+
+    assertThat(actual).isEqualTo(p);
+  }
+
   private static TypeUsage findBaseTypeUsage(
       @SuppressWarnings("rawtypes") Class<? extends BaseConvertablePropertyTest> c) {
     TypeUsage curr = TypeUsage.of(c);
@@ -70,4 +119,36 @@ abstract class BaseConvertablePropertyTest<ModelT, ProtoT extends Message> {
     }
     return curr;
   }
+
+  @SuppressWarnings("unchecked")
+  @Nullable
+  private <X> Codec<ModelT, X> resolveCodec(Object instance, TypeUsage xType) {
+    Method[] declaredMethods = instance.getClass().getDeclaredMethods();
+    Method method = findCodecMethod(declaredMethods, instance.getClass(), xType);
+    try {
+      return (Codec<ModelT, X>) method.invoke(instance);
+    } catch (IllegalAccessException | InvocationTargetException e) {
+      throw new AssertionError("error attempting to resolve codec", e);
+    }
+  }
+
+  private Method findCodecMethod(
+      Method[] declaredMethods, Class<?> conversionsClass, TypeUsage serializedType) {
+    Optional<Method> collect =
+        Arrays.stream(declaredMethods)
+            .filter(m -> m.getReturnType().isAssignableFrom(Codec.class))
+            .filter(
+                m -> {
+                  TypeUsage returnType = TypeUsage.forType(m.getGenericReturnType());
+                  return modelTType.equals(returnType.getTypeArgument(0))
+                      && serializedType.equals(returnType.getTypeArgument(1));
+                })
+            .findFirst();
+    assertWithMessage(
+            "Unable to locate Codec<%s, %s> method in %s",
+            modelTType, serializedType, conversionsClass)
+        .that(collect.isPresent())
+        .isTrue();
+    return collect.get();
+  }
 }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/BlobInfoPropertyTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/BlobInfoPropertyTest.java
index 0de3c53d..e70b07b3 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/BlobInfoPropertyTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/BlobInfoPropertyTest.java
@@ -16,13 +16,8 @@
 
 package com.google.cloud.storage;
 
-import com.google.cloud.storage.Conversions.Codec;
+import com.google.api.services.storage.model.StorageObject;
 import com.google.storage.v2.Object;
 
-final class BlobInfoPropertyTest extends BaseConvertablePropertyTest<BlobInfo, Object> {
-
-  @Override
-  Codec<BlobInfo, Object> codec() {
-    return Conversions.grpc().blobInfo();
-  }
-}
+final class BlobInfoPropertyTest
+    extends BaseConvertablePropertyTest<BlobInfo, Object, StorageObject> {}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/BucketPropertyTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/BucketPropertyTest.java
index 35232247..1cfdc48d 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/BucketPropertyTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/BucketPropertyTest.java
@@ -18,10 +18,6 @@ package com.google.cloud.storage;
 
 import com.google.storage.v2.Bucket;
 
-final class BucketInfoPropertyTest extends BaseConvertablePropertyTest<BucketInfo, Bucket> {
-
-  @Override
-  Conversions.Codec<BucketInfo, Bucket> codec() {
-    return Conversions.grpc().bucketInfo();
-  }
-}
+final class BucketInfoPropertyTest
+    extends BaseConvertablePropertyTest<
+        BucketInfo, Bucket, com.google.api.services.storage.model.Bucket> {}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/HmacKeyMetadataPropertyTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/HmacKeyMetadataPropertyTest.java
index 618a7ebc..4db13c94 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/HmacKeyMetadataPropertyTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/HmacKeyMetadataPropertyTest.java
@@ -15,14 +15,10 @@
  */
 package com.google.cloud.storage;
 
-import com.google.cloud.storage.Conversions.Codec;
 import com.google.storage.v2.HmacKeyMetadata;
 
 public class HmacKeyMetadataPropertyTest
-    extends BaseConvertablePropertyTest<HmacKey.HmacKeyMetadata, HmacKeyMetadata> {
-
-  @Override
-  Codec<HmacKey.HmacKeyMetadata, HmacKeyMetadata> codec() {
-    return Conversions.grpc().hmacKeyMetadata();
-  }
-}
+    extends BaseConvertablePropertyTest<
+        HmacKey.HmacKeyMetadata,
+        HmacKeyMetadata,
+        com.google.api.services.storage.model.HmacKeyMetadata> {}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ServiceAccountPropertyTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ServiceAccountPropertyTest.java
index 0f3bf064..a63f952a 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/ServiceAccountPropertyTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ServiceAccountPropertyTest.java
@@ -16,12 +16,8 @@
 
 package com.google.cloud.storage;
 
-import com.google.cloud.storage.Conversions.Codec;
-
 final class ServiceAccountPropertyTest
-    extends BaseConvertablePropertyTest<ServiceAccount, com.google.storage.v2.ServiceAccount> {
-  @Override
-  Codec<ServiceAccount, com.google.storage.v2.ServiceAccount> codec() {
-    return Conversions.grpc().serviceAccount();
-  }
-}
+    extends BaseConvertablePropertyTest<
+        ServiceAccount,
+        com.google.storage.v2.ServiceAccount,
+        com.google.api.services.storage.model.ServiceAccount> {}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/BucketArbitraryProvider.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/BucketArbitraryProvider.java
index 0ef3f8ff..5dd3d938 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/BucketArbitraryProvider.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/BucketArbitraryProvider.java
@@ -76,7 +76,6 @@ public final class BucketArbitraryProvider implements ArbitraryProvider {
                   Bucket.Builder b = Bucket.newBuilder();
                   ifNonNull(t1.get1(), BucketName::getBucket, b::setBucketId);
                   ifNonNull(t1.get2(), BucketName::toString, b::setName);
-                  ifNonNull(t1.get2(), BucketName::getProject, b::setProject);
                   ifNonNull(t1.get3(), b::setStorageClass);
                   ifNonNull(t1.get4(), b::setLocation);
                   ifNonNull(t1.get5(), b::setLocationType);
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/StorageArbitraries.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/StorageArbitraries.java
index 094fa53f..e098e077 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/StorageArbitraries.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/StorageArbitraries.java
@@ -57,7 +57,7 @@ public final class StorageArbitraries {
   public static Arbitrary<Timestamp> timestamp() {
     return Combinators.combine(
             DateTimes.offsetDateTimes().offsetBetween(ZoneOffset.UTC, ZoneOffset.UTC),
-            Arbitraries.integers().between(0, 999_999_999))
+            millisecondsAsNanos())
         .as(
             (odt, nanos) ->
                 Timestamp.newBuilder().setSeconds(odt.toEpochSecond()).setNanos(nanos).build());
@@ -149,10 +149,10 @@ public final class StorageArbitraries {
      * noreferrer"
      * href="https://cloud.google.com/storage/docs/naming-buckets#requirements">https://cloud.google.com/storage/docs/naming-buckets#requirements</a>
      */
-    Arbitrary<BucketName> name() {
+    public Arbitrary<BucketName> name() {
       return Combinators.combine(
               Arbitraries.oneOf(
-                  projectID(),
+                  // projectID(), TODO: reinclude this once we support non-global scoped buckets
                   // Global buckets have prefix of projects/_
                   Arbitraries.of(new ProjectID("_"))),
               alnum(),
@@ -264,18 +264,13 @@ public final class StorageArbitraries {
     public Arbitrary<Bucket.Logging> logging() {
       Arbitrary<BucketName> loggingBucketName = name();
       Arbitrary<String> loggingPrefix = Arbitraries.strings().all().ofMinLength(1).ofMaxLength(10);
-      return Combinators.combine(loggingBucketName, loggingPrefix, bool())
+      return Combinators.combine(loggingBucketName, loggingPrefix)
           .as(
-              (b, p, u) -> {
-                Bucket.Logging.Builder loggingBuilder =
-                    Bucket.Logging.newBuilder().setLogObjectPrefix(p);
-                if (u == Boolean.TRUE) {
-                  loggingBuilder.setLogBucket(b.toString());
-                } else {
-                  loggingBuilder.setLogBucket(b.getBucket());
-                }
-                return loggingBuilder.build();
-              });
+              (b, p) ->
+                  Bucket.Logging.newBuilder()
+                      .setLogObjectPrefix(p)
+                      .setLogBucket(b.toString())
+                      .build());
     }
 
     public ListArbitrary<Bucket.Cors> cors() {
@@ -548,4 +543,15 @@ public final class StorageArbitraries {
     }
     return chars;
   }
+
+  /**
+   * gRPC has nanosecond level precision for timestamps, whereas JSON is limited to millisecond
+   * precision due to limitations in {@link com.google.api.client.util.DateTime}.
+   *
+   * <p>Define an arbitrary, which will always produce a nanosecond value that is in the range of
+   * milliseconds.
+   */
+  private static Arbitrary<Integer> millisecondsAsNanos() {
+    return Arbitraries.integers().between(0, 999).map(i -> i * 1_000_000);
+  }
 }