Repository: googleapis/java-storage
Pull request #1568 (2022-08-11 23:34:17)

chore: remove ZOpt replacing with UnifiedOpts
Update all usage of UnifiedOpts in GrpcStorageImpl to instead use Request mappers rather than ZOpt.

Add suite of unit tests for new gRPC request mappers.

Follow up of #1546 


Patch:
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 9d840d5c..16b5a5af 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
@@ -18,6 +18,7 @@ package com.google.cloud.storage;
 
 import static com.google.cloud.storage.Utils.bucketNameCodec;
 import static com.google.cloud.storage.Utils.ifNonNull;
+import static com.google.cloud.storage.Utils.projectNameCodec;
 import static com.google.cloud.storage.Utils.todo;
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static java.util.Objects.requireNonNull;
@@ -41,18 +42,20 @@ import com.google.cloud.storage.PostPolicyV4.PostConditionsV4;
 import com.google.cloud.storage.PostPolicyV4.PostFieldsV4;
 import com.google.cloud.storage.UnbufferedReadableByteChannelSession.UnbufferedReadableByteChannel;
 import com.google.cloud.storage.UnbufferedWritableByteChannelSession.UnbufferedWritableByteChannel;
+import com.google.cloud.storage.UnifiedOpts.BucketListOpt;
+import com.google.cloud.storage.UnifiedOpts.BucketSourceOpt;
+import com.google.cloud.storage.UnifiedOpts.BucketTargetOpt;
+import com.google.cloud.storage.UnifiedOpts.HmacKeyListOpt;
+import com.google.cloud.storage.UnifiedOpts.HmacKeyTargetOpt;
+import com.google.cloud.storage.UnifiedOpts.ObjectListOpt;
+import com.google.cloud.storage.UnifiedOpts.ObjectSourceOpt;
+import com.google.cloud.storage.UnifiedOpts.ObjectTargetOpt;
 import com.google.cloud.storage.UnifiedOpts.Opts;
-import com.google.cloud.storage.spi.v1.StorageRpc;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
+import com.google.cloud.storage.UnifiedOpts.ProjectId;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.hash.HashCode;
-import com.google.common.hash.Hashing;
 import com.google.common.io.ByteStreams;
-import com.google.protobuf.ByteString;
 import com.google.protobuf.FieldMask;
 import com.google.protobuf.Message;
-import com.google.storage.v2.CommonObjectRequestParams;
 import com.google.storage.v2.ComposeObjectRequest;
 import com.google.storage.v2.ComposeObjectRequest.SourceObject;
 import com.google.storage.v2.CreateBucketRequest;
@@ -96,14 +99,11 @@ import java.nio.file.OpenOption;
 import java.nio.file.Path;
 import java.nio.file.StandardOpenOption;
 import java.util.Arrays;
-import java.util.Base64;
 import java.util.Iterator;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
-import org.checkerframework.checker.nullness.qual.NonNull;
 
 final class GrpcStorageImpl extends BaseService<StorageOptions> implements Storage {
 
@@ -122,12 +122,15 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
   private final GrpcRetryAlgorithmManager retryAlgorithmManager;
   private final SyntaxDecoders syntaxDecoders;
 
+  private final transient ProjectId defaultProjectId;
+
   GrpcStorageImpl(GrpcStorageOptions options, GrpcStorageStub grpcStorageStub) {
     super(options);
     this.grpcStorageStub = grpcStorageStub;
     this.codecs = Conversions.grpc();
     this.retryAlgorithmManager = options.getRetryAlgorithmManager();
     this.syntaxDecoders = new SyntaxDecoders();
+    this.defaultProjectId = UnifiedOpts.projectId(options.getProjectId());
   }
 
   @Override
@@ -139,20 +142,16 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public Bucket create(BucketInfo bucketInfo, BucketTargetOption... options) {
-    ImmutableMap<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(options).resolveFrom(bucketInfo).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
+    Opts<BucketTargetOpt> opts = Opts.unwrap(options).resolveFrom(bucketInfo);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     com.google.storage.v2.Bucket bucket = codecs.bucketInfo().encode(bucketInfo);
-    CreateBucketRequest.Builder builder = CreateBucketRequest.newBuilder().setBucket(bucket);
-    builder.setBucketId(bucketInfo.getName());
-    builder.setParent(ProjectName.format(getOptions().getProjectId()));
-    ZOpt.applyAll(
-        optionsMap,
-        ZOpt.PREDEFINED_ACL.consumeVia(builder::setPredefinedAcl),
-        ZOpt.PREDEFINED_DEFAULT_OBJECT_ACL.consumeVia(builder::setPredefinedDefaultObjectAcl));
-    CreateBucketRequest req = builder.build();
-    // TODO(frankyn): Do we care about projection because Apiary uses FULL for projection? Missing
-    // projection=full
+    CreateBucketRequest.Builder builder =
+        CreateBucketRequest.newBuilder()
+            .setBucket(bucket)
+            .setBucketId(bucketInfo.getName())
+            .setParent(ProjectName.format(getOptions().getProjectId()));
+    CreateBucketRequest req = opts.createBucketsRequest().apply(builder).build();
     return Retrying.run(
         getOptions(),
         retryAlgorithmManager.getFor(req),
@@ -176,10 +175,10 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
       BlobInfo blobInfo, byte[] content, int offset, int length, BlobTargetOption... options) {
     requireNonNull(blobInfo, "blobInfo must be non null");
     requireNonNull(content, "content must be non null");
-    ImmutableMap<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(options).resolveFrom(blobInfo).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
-    WriteObjectRequest req = getWriteObjectRequestBuilder(blobInfo, optionsMap).build();
+    Opts<ObjectTargetOpt> opts = Opts.unwrap(options).resolveFrom(blobInfo);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
+    WriteObjectRequest req = getWriteObjectRequest(blobInfo, opts);
     try {
       UnbufferedWritableByteChannelSession<WriteObjectResponse> session =
           ResumableMedia.gapic()
@@ -225,10 +224,10 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
       throw new StorageException(0, path + " is a directory");
     }
 
-    ImmutableMap<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(options).resolveFrom(blobInfo).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
-    WriteObjectRequest req = getWriteObjectRequestBuilder(blobInfo, optionsMap).build();
+    Opts<ObjectTargetOpt> opts = Opts.unwrap(options).resolveFrom(blobInfo);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
+    WriteObjectRequest req = getWriteObjectRequest(blobInfo, opts);
 
     GapicWritableByteChannelSessionBuilder channelSessionBuilder =
         ResumableMedia.gapic()
@@ -282,10 +281,10 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
       throws IOException {
     requireNonNull(blobInfo, "blobInfo must be non null");
 
-    ImmutableMap<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(options).resolveFrom(blobInfo).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
-    WriteObjectRequest req = getWriteObjectRequestBuilder(blobInfo, optionsMap).build();
+    Opts<ObjectTargetOpt> opts = Opts.unwrap(options).resolveFrom(blobInfo);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
+    WriteObjectRequest req = getWriteObjectRequest(blobInfo, opts);
 
     ApiFuture<ResumableWrite> start =
         ResumableMedia.gapic()
@@ -321,15 +320,12 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public Bucket get(String bucket, BucketGetOption... options) {
-    ImmutableMap<StorageRpc.Option, ?> optionsMap = Opts.unwrap(options).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
+    Opts<BucketSourceOpt> opts = Opts.unwrap(options);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     GetBucketRequest.Builder builder =
         GetBucketRequest.newBuilder().setName(bucketNameCodec.encode(bucket));
-    ZOpt.applyAll(
-        optionsMap,
-        ZOpt.IF_METAGENERATION_MATCH.consumeVia(builder::setIfMetagenerationMatch),
-        ZOpt.IF_METAGENERATION_NOT_MATCH.consumeVia(builder::setIfMetagenerationNotMatch));
-    GetBucketRequest req = builder.build();
+    GetBucketRequest req = opts.getBucketsRequest().apply(builder).build();
     // TODO(frankyn): Do we care about projection because Apiary uses FULL for projection? Missing
     // projection=full
     return Retrying.run(
@@ -351,17 +347,12 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public Blob get(BlobId blob, BlobGetOption... options) {
-    ImmutableMap<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(options).resolveFrom(blob).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
+    Opts<ObjectSourceOpt> opts = Opts.unwrap(options).resolveFrom(blob);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     GetObjectRequest.Builder builder =
         GetObjectRequest.newBuilder().setBucket(blob.getBucket()).setObject(blob.getName());
-    ZOpt.applyAll(
-        optionsMap,
-        ZOpt.IF_METAGENERATION_MATCH.consumeVia(builder::setIfMetagenerationMatch),
-        ZOpt.IF_METAGENERATION_NOT_MATCH.consumeVia(builder::setIfMetagenerationNotMatch));
-    // TODO(sydmunro) StorageRpc.Option.Fields
-    GetObjectRequest req = builder.build();
+    GetObjectRequest req = opts.getObjectsRequest().apply(builder).build();
     return Retrying.run(
         getOptions(),
         retryAlgorithmManager.getFor(req),
@@ -378,19 +369,13 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
   public Page<Bucket> list(BucketListOption... options) {
     UnaryCallable<ListBucketsRequest, ListBucketsPagedResponse> listBucketsCallable =
         grpcStorageStub.listBucketsPagedCallable();
-    ImmutableMap<StorageRpc.Option, ?> optionsMap = Opts.unwrap(options).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
-    String projectId =
-        firstNonNull(ZOpt.PROJECT_ID.get(optionsMap), this.getOptions().getProjectId());
+    Opts<BucketListOpt> opts = Opts.unwrap(options);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     ListBucketsRequest.Builder builder =
-        ListBucketsRequest.newBuilder().setParent(ProjectName.format(projectId));
-    ZOpt.applyAll(
-        optionsMap,
-        ZOpt.MAX_RESULTS.mapThenConsumeVia(Long::intValue, builder::setPageSize),
-        ZOpt.PAGE_TOKEN.consumeVia(builder::setPageToken),
-        ZOpt.PREFIX.consumeVia(builder::setPrefix));
-    // TODO(sydmunro): StorageRpc.Option.Fields
-    ListBucketsPagedResponse call = listBucketsCallable.call(builder.build(), grpcCallContext);
+        ListBucketsRequest.newBuilder().setParent(projectNameCodec.encode(defaultProjectId.val));
+    ListBucketsRequest request = opts.listBucketsRequest().apply(builder).build();
+    ListBucketsPagedResponse call = listBucketsCallable.call(request, grpcCallContext);
     ListBucketsPage page = call.getPage();
     return new TransformingPageDecorator<>(page, syntaxDecoders.bucket);
   }
@@ -399,20 +384,12 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
   public Page<Blob> list(String bucket, BlobListOption... options) {
     UnaryCallable<ListObjectsRequest, ListObjectsPagedResponse> listObjectsCallable =
         grpcStorageStub.listObjectsPagedCallable();
-    ImmutableMap<StorageRpc.Option, ?> optionsMap = Opts.unwrap(options).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
+    Opts<ObjectListOpt> opts = Opts.unwrap(options);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     ListObjectsRequest.Builder builder =
         ListObjectsRequest.newBuilder().setParent(bucketNameCodec.encode(bucket));
-    ZOpt.applyAll(
-        optionsMap,
-        ZOpt.MAX_RESULTS.mapThenConsumeVia(Long::intValue, builder::setPageSize),
-        ZOpt.PAGE_TOKEN.consumeVia(builder::setPageToken),
-        ZOpt.PREFIX.consumeVia(builder::setPrefix),
-        ZOpt.DELIMITER.consumeVia(builder::setDelimiter),
-        ZOpt.START_OFF_SET.consumeVia(builder::setLexicographicStart),
-        ZOpt.END_OFF_SET.consumeVia(builder::setLexicographicEnd));
-    // TODO(sydmunro) StorageRpc.Option.Fields
-    ListObjectsRequest req = builder.build();
+    ListObjectsRequest req = opts.listObjectsRequest().apply(builder).build();
     ListObjectsPagedResponse call = listObjectsCallable.call(req, grpcCallContext);
     ListObjectsPage page = call.getPage();
     return new TransformingPageDecorator<>(page, syntaxDecoders.blob);
@@ -420,18 +397,16 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public Bucket update(BucketInfo bucketInfo, BucketTargetOption... options) {
-    ImmutableMap<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(options).resolveFrom(bucketInfo).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
+    Opts<BucketTargetOpt> opts = Opts.unwrap(options).resolveFrom(bucketInfo);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     com.google.storage.v2.Bucket bucket = codecs.bucketInfo().encode(bucketInfo);
     UpdateBucketRequest.Builder builder = UpdateBucketRequest.newBuilder().setBucket(bucket);
-    builder.setUpdateMask(fieldMaskGenerator(bucket));
-    ZOpt.applyAll(
-        optionsMap,
-        ZOpt.PREDEFINED_ACL.consumeVia(builder::setPredefinedAcl),
-        ZOpt.IF_METAGENERATION_MATCH.consumeVia(builder::setIfMetagenerationMatch),
-        ZOpt.IF_METAGENERATION_NOT_MATCH.consumeVia(builder::setIfMetagenerationNotMatch));
-    UpdateBucketRequest req = builder.build();
+    UpdateBucketRequest req =
+        opts.updateBucketsRequest()
+            .apply(builder)
+            .setUpdateMask(fieldMaskGenerator(bucket))
+            .build();
 
     return Retrying.run(
         getOptions(),
@@ -442,20 +417,16 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public Blob update(BlobInfo blobInfo, BlobTargetOption... options) {
-    ImmutableMap<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(options).resolveFrom(blobInfo).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
+    Opts<ObjectTargetOpt> opts = Opts.unwrap(options).resolveFrom(blobInfo);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     Object object = codecs.blobInfo().encode(blobInfo);
     UpdateObjectRequest.Builder builder = UpdateObjectRequest.newBuilder().setObject(object);
-    ZOpt.applyAll(
-        optionsMap,
-        ZOpt.PREDEFINED_ACL.consumeVia(builder::setPredefinedAcl),
-        ZOpt.IF_METAGENERATION_MATCH.consumeVia(builder::setIfMetagenerationMatch),
-        ZOpt.IF_METAGENERATION_NOT_MATCH.consumeVia(builder::setIfMetagenerationNotMatch),
-        ZOpt.IF_GENERATION_MATCH.consumeVia(builder::setIfGenerationMatch),
-        ZOpt.IF_GENERATION_NOT_MATCH.consumeVia(builder::setIfGenerationNotMatch));
-    builder.setUpdateMask(fieldMaskGenerator(object));
-    UpdateObjectRequest req = builder.build();
+    UpdateObjectRequest req =
+        opts.updateObjectsRequest()
+            .apply(builder)
+            .setUpdateMask(fieldMaskGenerator(object))
+            .build();
     return Retrying.run(
         getOptions(),
         retryAlgorithmManager.getFor(req),
@@ -470,15 +441,12 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public boolean delete(String bucket, BucketSourceOption... options) {
-    ImmutableMap<StorageRpc.Option, ?> optionsMap = Opts.unwrap(options).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
+    Opts<BucketSourceOpt> opts = Opts.unwrap(options);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     DeleteBucketRequest.Builder builder =
         DeleteBucketRequest.newBuilder().setName(bucketNameCodec.encode(bucket));
-    ZOpt.applyAll(
-        optionsMap,
-        ZOpt.IF_METAGENERATION_MATCH.consumeVia(builder::setIfMetagenerationMatch),
-        ZOpt.IF_METAGENERATION_NOT_MATCH.consumeVia(builder::setIfMetagenerationNotMatch));
-    DeleteBucketRequest req = builder.build();
+    DeleteBucketRequest req = opts.deleteBucketsRequest().apply(builder).build();
     try {
       Retrying.run(
           getOptions(),
@@ -499,19 +467,13 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public boolean delete(BlobId blob, BlobSourceOption... options) {
-    ImmutableMap<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(options).resolveFrom(blob).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
+    Opts<ObjectSourceOpt> opts = Opts.unwrap(options).resolveFrom(blob);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     DeleteObjectRequest.Builder builder =
         DeleteObjectRequest.newBuilder().setBucket(blob.getBucket()).setObject(blob.getName());
     ifNonNull(blob.getGeneration(), builder::setGeneration);
-    ZOpt.applyAll(
-        optionsMap,
-        ZOpt.IF_METAGENERATION_MATCH.consumeVia(builder::setIfMetagenerationMatch),
-        ZOpt.IF_METAGENERATION_NOT_MATCH.consumeVia(builder::setIfMetagenerationNotMatch),
-        ZOpt.IF_GENERATION_MATCH.consumeVia(builder::setIfGenerationMatch),
-        ZOpt.IF_GENERATION_NOT_MATCH.consumeVia(builder::setIfGenerationNotMatch));
-    DeleteObjectRequest req = builder.build();
+    DeleteObjectRequest req = opts.deleteObjectsRequest().apply(builder).build();
     try {
       Retrying.run(
           getOptions(),
@@ -527,16 +489,15 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public boolean delete(BlobId blob) {
-    return delete(blob);
+    return delete(blob, new BlobSourceOption[0]);
   }
 
   @Override
   public Blob compose(ComposeRequest composeRequest) {
-    final Map<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(composeRequest.getTargetOptions())
-            .resolveFrom(composeRequest.getTarget())
-            .getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
+    Opts<ObjectTargetOpt> opts =
+        Opts.unwrap(composeRequest.getTargetOptions()).resolveFrom(composeRequest.getTarget());
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     ComposeObjectRequest.Builder builder = ComposeObjectRequest.newBuilder();
     composeRequest.getSourceBlobs().stream()
         .map(
@@ -548,15 +509,7 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
         .forEach(builder::addSourceObjects);
     final Object target = codecs.blobInfo().encode(composeRequest.getTarget());
     builder.setDestination(target);
-    ZOpt.applyAll(
-        optionsMap,
-        ZOpt.IF_GENERATION_MATCH.consumeVia(builder::setIfGenerationMatch),
-        ZOpt.IF_METAGENERATION_MATCH.consumeVia(builder::setIfMetagenerationMatch),
-        ZOpt.PREDEFINED_ACL.consumeVia(builder::setDestinationPredefinedAcl),
-        ZOpt.KMS_KEY_NAME.consumeVia(builder::setKmsKey),
-        ZOpt.CUSTOMER_SUPPLIED_KEY.mapThenConsumeVia(
-            this::commonRequestParams, builder::setCommonObjectRequestParams));
-    ComposeObjectRequest req = builder.build();
+    ComposeObjectRequest req = opts.composeObjectsRequest().apply(builder).build();
     return Retrying.run(
         getOptions(),
         retryAlgorithmManager.getFor(req),
@@ -576,11 +529,8 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public byte[] readAllBytes(BlobId blob, BlobSourceOption... options) {
-
-    ImmutableMap<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(options).resolveFrom(blob).getRpcOptions();
-
-    ReadObjectRequest readObjectRequest = getReadObjectRequest(blob, optionsMap);
+    Opts<ObjectSourceOpt> opts = Opts.unwrap(options).resolveFrom(blob);
+    ReadObjectRequest readObjectRequest = getReadObjectRequest(blob, opts);
     UnbufferedReadableByteChannelSession<Object> session =
         ResumableMedia.gapic()
             .read()
@@ -611,21 +561,19 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public GrpcBlobReadChannel reader(BlobId blob, BlobSourceOption... options) {
-    ImmutableMap<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(options).resolveFrom(blob).getRpcOptions();
-    ReadObjectRequest request = getReadObjectRequest(blob, optionsMap);
+    Opts<ObjectSourceOpt> opts = Opts.unwrap(options).resolveFrom(blob);
+    ReadObjectRequest request = getReadObjectRequest(blob, opts);
     return new GrpcBlobReadChannel(grpcStorageStub.readObjectCallable(), request);
   }
 
   @Override
   public void downloadTo(BlobId blob, Path path, BlobSourceOption... options) {
 
-    ImmutableMap<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(options).resolveFrom(blob).getRpcOptions();
+    Opts<ObjectSourceOpt> opts = Opts.unwrap(options).resolveFrom(blob);
 
     // TODO: handle StorageRpc.Option.RETURN_RAW_INPUT_STREAM impacts
 
-    ReadObjectRequest readObjectRequest = getReadObjectRequest(blob, optionsMap);
+    ReadObjectRequest readObjectRequest = getReadObjectRequest(blob, opts);
     UnbufferedReadableByteChannelSession<Object> session =
         ResumableMedia.gapic()
             .read()
@@ -645,12 +593,11 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
   @Override
   public void downloadTo(BlobId blob, OutputStream outputStream, BlobSourceOption... options) {
 
-    ImmutableMap<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(options).resolveFrom(blob).getRpcOptions();
+    Opts<ObjectSourceOpt> opts = Opts.unwrap(options).resolveFrom(blob);
 
     // TODO: handle StorageRpc.Option.RETURN_RAW_INPUT_STREAM impacts
 
-    ReadObjectRequest readObjectRequest = getReadObjectRequest(blob, optionsMap);
+    ReadObjectRequest readObjectRequest = getReadObjectRequest(blob, opts);
     UnbufferedReadableByteChannelSession<Object> session =
         ResumableMedia.gapic()
             .read()
@@ -669,10 +616,10 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public GrpcBlobWriteChannel writer(BlobInfo blobInfo, BlobWriteOption... options) {
-    ImmutableMap<StorageRpc.Option, ?> optionsMap =
-        Opts.unwrap(options).resolveFrom(blobInfo).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
-    WriteObjectRequest req = getWriteObjectRequestBuilder(blobInfo, optionsMap).build();
+    Opts<ObjectTargetOpt> opts = Opts.unwrap(options).resolveFrom(blobInfo);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
+    WriteObjectRequest req = getWriteObjectRequest(blobInfo, opts);
     return new GrpcBlobWriteChannel(
         grpcStorageStub.writeObjectCallable(),
         () ->
@@ -900,18 +847,16 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
   public Page<HmacKeyMetadata> listHmacKeys(ListHmacKeysOption... options) {
     UnaryCallable<ListHmacKeysRequest, ListHmacKeysPagedResponse> listHmacKeysCallable =
         grpcStorageStub.listHmacKeysPagedCallable();
-    ImmutableMap<StorageRpc.Option, ?> optionsMap = Opts.unwrap(options).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
-    String projectId =
-        firstNonNull(ZOpt.PROJECT_ID.get(optionsMap), this.getOptions().getProjectId());
-    ListHmacKeysRequest.Builder builder = ListHmacKeysRequest.newBuilder().setProject(projectId);
-    ZOpt.applyAll(
-        optionsMap,
-        ZOpt.SERVICE_ACCOUNT_EMAIL.consumeVia(builder::setServiceAccountEmail),
-        ZOpt.MAX_RESULTS.mapThenConsumeVia(Long::intValue, builder::setPageSize),
-        ZOpt.PAGE_TOKEN.consumeVia(builder::setPageToken),
-        ZOpt.SHOW_DELETED_KEYS.consumeVia(builder::setShowDeletedKeys));
-    ListHmacKeysPagedResponse call = listHmacKeysCallable.call(builder.build(), grpcCallContext);
+    Opts<HmacKeyListOpt> opts = Opts.unwrap(options);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
+
+    ProjectId projectId = opts.projectId().orElse(defaultProjectId);
+
+    ListHmacKeysRequest.Builder builder =
+        projectId.listHmacKeys().apply(ListHmacKeysRequest.newBuilder());
+    ListHmacKeysRequest request = opts.listHmacKeysRequest().apply(builder).build();
+    ListHmacKeysPagedResponse call = listHmacKeysCallable.call(request, grpcCallContext);
     ListHmacKeysPage page = call.getPage();
     return new TransformingPageDecorator<>(page, codecs.hmacKeyMetadata());
   }
@@ -923,8 +868,9 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public void deleteHmacKey(HmacKeyMetadata hmacKeyMetadata, DeleteHmacKeyOption... options) {
-    ImmutableMap<StorageRpc.Option, ?> optionsMap = Opts.unwrap(options).getRpcOptions();
-    GrpcCallContext grpcCallContext = GrpcRequestMetadataSupport.create(optionsMap);
+    Opts<HmacKeyTargetOpt> opts = Opts.unwrap(options);
+    GrpcCallContext grpcCallContext =
+        opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     DeleteHmacKeyRequest req =
         DeleteHmacKeyRequest.newBuilder()
             .setAccessId(hmacKeyMetadata.getAccessId())
@@ -1096,8 +1042,7 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
         + ")";
   }
 
-  private ReadObjectRequest getReadObjectRequest(
-      BlobId blob, Map<StorageRpc.Option, ?> optionsMap) {
+  private ReadObjectRequest getReadObjectRequest(BlobId blob, Opts<ObjectSourceOpt> opts) {
     Object object = codecs.blobId().encode(blob);
 
     ReadObjectRequest.Builder builder =
@@ -1107,35 +1052,10 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
     if (generation > 0) {
       builder.setGeneration(generation);
     }
-    ZOpt.applyAll(
-        optionsMap,
-        ZOpt.IF_METAGENERATION_MATCH.consumeVia(builder::setIfMetagenerationMatch),
-        ZOpt.IF_METAGENERATION_NOT_MATCH.consumeVia(builder::setIfMetagenerationNotMatch),
-        ZOpt.IF_GENERATION_MATCH.consumeVia(builder::setIfGenerationMatch),
-        ZOpt.IF_GENERATION_NOT_MATCH.consumeVia(builder::setIfGenerationNotMatch),
-        ZOpt.CUSTOMER_SUPPLIED_KEY.mapThenConsumeVia(
-            this::commonRequestParams, builder::setCommonObjectRequestParams));
-    return builder.build();
-  }
-
-  private static final class GrpcRequestMetadataSupport {
-    @NonNull
-    static GrpcCallContext create(Map<StorageRpc.Option, ?> optionsMap) {
-      // GrpcCallContext is immutable, any modification we perform needs to be assigned back to
-      // our variable otherwise it will effectively be lost.
-      GrpcCallContext ctx = GrpcCallContext.createDefault();
-      String userProject = ZOpt.USER_PROJECT.get(optionsMap);
-      if (userProject != null) {
-        ctx =
-            ctx.withExtraHeaders(
-                ImmutableMap.of("X-Goog-User-Project", ImmutableList.of(userProject)));
-      }
-      return ctx;
-    }
+    return opts.readObjectRequest().apply(builder).build();
   }
 
-  private WriteObjectRequest.Builder getWriteObjectRequestBuilder(
-      BlobInfo info, Map<StorageRpc.Option, ?> optionsMap) {
+  private WriteObjectRequest getWriteObjectRequest(BlobInfo info, Opts<ObjectTargetOpt> opts) {
     Object object = codecs.blobInfo().encode(info);
     Object.Builder objectBuilder =
         object
@@ -1143,7 +1063,6 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
             // required if the data is changing
             .clearChecksums()
             // trimmed to shave payload size
-            .clearAcl()
             .clearGeneration()
             .clearMetageneration()
             .clearSize()
@@ -1154,30 +1073,7 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
     WriteObjectRequest.Builder requestBuilder =
         WriteObjectRequest.newBuilder().setWriteObjectSpec(specBuilder);
 
-    // TODO: Projection: Do we care?
-    ZOpt.applyAll(
-        optionsMap,
-        ZOpt.PREDEFINED_ACL.consumeVia(specBuilder::setPredefinedAcl),
-        ZOpt.IF_METAGENERATION_MATCH.consumeVia(specBuilder::setIfMetagenerationMatch),
-        ZOpt.IF_METAGENERATION_NOT_MATCH.consumeVia(specBuilder::setIfMetagenerationNotMatch),
-        ZOpt.IF_GENERATION_MATCH.consumeVia(specBuilder::setIfGenerationMatch),
-        ZOpt.IF_GENERATION_NOT_MATCH.consumeVia(specBuilder::setIfGenerationNotMatch),
-        ZOpt.CUSTOMER_SUPPLIED_KEY.mapThenConsumeVia(
-            this::commonRequestParams, requestBuilder::setCommonObjectRequestParams),
-        ZOpt.KMS_KEY_NAME.consumeVia(objectBuilder::setKmsKey));
-
-    return requestBuilder;
-  }
-
-  private CommonObjectRequestParams commonRequestParams(String key) {
-    byte[] keyBytes = Base64.getDecoder().decode(key);
-    HashCode keySha256 = Hashing.sha256().hashBytes(keyBytes);
-
-    return CommonObjectRequestParams.newBuilder()
-        .setEncryptionAlgorithm("AES256")
-        .setEncryptionKeyBytes(ByteString.copyFromUtf8(key))
-        .setEncryptionKeySha256Bytes(ByteString.copyFrom(keySha256.asBytes()))
-        .build();
+    return opts.writeObjectRequest().apply(requestBuilder).build();
   }
 
   private FieldMask fieldMaskGenerator(Message message) {
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java
index 757578fc..78181327 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java
@@ -1319,6 +1319,7 @@ final class StorageImpl extends BaseService<StorageOptions> implements Storage {
                 .collect(ImmutableList.toImmutableList()));
   }
 
+  @Override
   public HmacKey createHmacKey(
       final ServiceAccount serviceAccount, final CreateHmacKeyOption... options) {
     String pb = serviceAccount.getEmail();
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/UnifiedOpts.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/UnifiedOpts.java
index 8b94b6c8..32b2e42b 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/UnifiedOpts.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/UnifiedOpts.java
@@ -16,6 +16,7 @@
 
 package com.google.cloud.storage;
 
+import static com.google.cloud.storage.Utils.projectNameCodec;
 import static com.google.cloud.storage.Utils.todo;
 import static com.google.common.base.Preconditions.checkArgument;
 
@@ -24,8 +25,30 @@ import com.google.cloud.storage.spi.v1.StorageRpc;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.hash.HashCode;
+import com.google.common.hash.Hashing;
 import com.google.common.io.BaseEncoding;
+import com.google.iam.v1.GetIamPolicyRequest;
+import com.google.protobuf.ByteString;
+import com.google.storage.v2.CommonObjectRequestParams;
+import com.google.storage.v2.ComposeObjectRequest;
+import com.google.storage.v2.CreateBucketRequest;
+import com.google.storage.v2.CreateHmacKeyRequest;
+import com.google.storage.v2.DeleteBucketRequest;
+import com.google.storage.v2.DeleteHmacKeyRequest;
+import com.google.storage.v2.DeleteObjectRequest;
+import com.google.storage.v2.GetBucketRequest;
+import com.google.storage.v2.GetHmacKeyRequest;
 import com.google.storage.v2.GetObjectRequest;
+import com.google.storage.v2.ListBucketsRequest;
+import com.google.storage.v2.ListHmacKeysRequest;
+import com.google.storage.v2.ListObjectsRequest;
+import com.google.storage.v2.ReadObjectRequest;
+import com.google.storage.v2.RewriteObjectRequest;
+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 java.io.Serializable;
 import java.net.FileNameMap;
 import java.net.URLConnection;
@@ -34,6 +57,7 @@ import java.util.Arrays;
 import java.util.Collection;
 import java.util.Locale;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.function.Function;
 import java.util.function.Predicate;
 import java.util.stream.Stream;
@@ -99,14 +123,28 @@ final class UnifiedOpts {
   }
 
   /** Base interface for those Opts which are applicable to Object List operations */
-  interface ObjectListOpt extends GrpcMetadataMapper, ListOpt, ApplicableObject {}
+  interface ObjectListOpt extends GrpcMetadataMapper, ListOpt, ApplicableObject {
+    default Mapper<ListObjectsRequest.Builder> listObjects() {
+      return Mapper.identity();
+    }
+  }
 
   /**
    * Base interface for those Opts which are applicable to Object Source (get/read/origin
    * relationship) operations
    */
   interface ObjectSourceOpt extends GrpcMetadataMapper, SourceOpt, ApplicableObject {
-    Mapper<GetObjectRequest.Builder> getObject();
+    default Mapper<ReadObjectRequest.Builder> readObject() {
+      return Mapper.identity();
+    }
+
+    default Mapper<GetObjectRequest.Builder> getObject() {
+      return Mapper.identity();
+    }
+
+    default Mapper<RewriteObjectRequest.Builder> rewriteObject() {
+      return Mapper.identity();
+    }
   }
 
   /**
@@ -117,37 +155,101 @@ final class UnifiedOpts {
     default Mapper<BlobInfo.Builder> blobInfo() {
       return Mapper.identity();
     }
+
+    default Mapper<WriteObjectRequest.Builder> writeObject() {
+      return Mapper.identity();
+    }
+
+    default Mapper<UpdateObjectRequest.Builder> updateObject() {
+      return Mapper.identity();
+    }
+
+    default Mapper<DeleteObjectRequest.Builder> deleteObject() {
+      return Mapper.identity();
+    }
+
+    default Mapper<ComposeObjectRequest.Builder> composeObject() {
+      return Mapper.identity();
+    }
+
+    default Mapper<RewriteObjectRequest.Builder> rewriteObject() {
+      return Mapper.identity();
+    }
   }
 
   /** Base interface for those Opts which are applicable to Bucket List operations */
-  interface BucketListOpt extends GrpcMetadataMapper, ListOpt, ApplicableBucket {}
+  interface BucketListOpt extends GrpcMetadataMapper, ListOpt, ApplicableBucket {
+    default Mapper<ListBucketsRequest.Builder> listBuckets() {
+      return Mapper.identity();
+    }
+  }
 
   /**
    * Base interface for those Opts which are applicable to Bucket Source (get/read/origin
    * relationship) operations
    */
-  interface BucketSourceOpt extends GrpcMetadataMapper, SourceOpt, ApplicableBucket {}
+  interface BucketSourceOpt extends GrpcMetadataMapper, SourceOpt, ApplicableBucket {
+    default Mapper<GetBucketRequest.Builder> getBucket() {
+      return Mapper.identity();
+    }
+
+    default Mapper<GetIamPolicyRequest.Builder> getIamPolicy() {
+      return Mapper.identity();
+    }
+  }
 
   /**
    * Base interface for those Opts which are applicable to Bucket Target (set/write/destination
    * relationship) operations
    */
-  interface BucketTargetOpt extends GrpcMetadataMapper, TargetOpt, ApplicableBucket {}
+  interface BucketTargetOpt extends GrpcMetadataMapper, TargetOpt, ApplicableBucket {
+    default Mapper<CreateBucketRequest.Builder> createBucket() {
+      return Mapper.identity();
+    }
+
+    default Mapper<UpdateBucketRequest.Builder> updateBucket() {
+      return Mapper.identity();
+    }
+
+    default Mapper<DeleteBucketRequest.Builder> deleteBucket() {
+      return Mapper.identity();
+    }
+  }
 
   /** Base interface for those Opts which are applicable to HmacKey List operations */
-  interface HmacKeyListOpt extends GrpcMetadataMapper, ListOpt, ApplicableHmacKey {}
+  interface HmacKeyListOpt extends GrpcMetadataMapper, ListOpt, ApplicableHmacKey {
+    default Mapper<ListHmacKeysRequest.Builder> listHmacKeys() {
+      return Mapper.identity();
+    }
+  }
 
   /**
    * Base interface for those Opts which are applicable to HmacKey Source (get/read/origin
    * relationship) operations
    */
-  interface HmacKeySourceOpt extends GrpcMetadataMapper, SourceOpt, ApplicableHmacKey {}
+  interface HmacKeySourceOpt extends GrpcMetadataMapper, SourceOpt, ApplicableHmacKey {
+    default Mapper<GetHmacKeyRequest.Builder> getHmacKey() {
+      return Mapper.identity();
+    }
+  }
 
   /**
    * Base interface for those Opts which are applicable to HmacKey Target (set/write/destination
    * relationship) operations
    */
-  interface HmacKeyTargetOpt extends GrpcMetadataMapper, TargetOpt, ApplicableHmacKey {}
+  interface HmacKeyTargetOpt extends GrpcMetadataMapper, TargetOpt, ApplicableHmacKey {
+    default Mapper<CreateHmacKeyRequest.Builder> createHmacKey() {
+      return Mapper.identity();
+    }
+
+    default Mapper<UpdateHmacKeyRequest.Builder> updateHmacKey() {
+      return Mapper.identity();
+    }
+
+    default Mapper<DeleteHmacKeyRequest.Builder> deleteHmacKey() {
+      return Mapper.identity();
+    }
+  }
 
   /**
    * Some Options have a corresponding "SOURCE" version, this interface provide a construct for
@@ -183,6 +285,11 @@ final class UnifiedOpts {
     default Mapper<GetObjectRequest.Builder> getObject() {
       return Mapper.identity();
     }
+
+    @Override
+    default Mapper<RewriteObjectRequest.Builder> rewriteObject() {
+      return Mapper.identity();
+    }
   }
 
   /**
@@ -325,6 +432,11 @@ final class UnifiedOpts {
     return new ServiceAccount(s.getEmail());
   }
 
+  @VisibleForTesting
+  static SetContentType setContentType(String s) {
+    return new SetContentType(s);
+  }
+
   static ShowDeletedKeys showDeletedKeys(boolean b) {
     return new ShowDeletedKeys(b);
   }
@@ -424,9 +536,31 @@ final class UnifiedOpts {
               BaseEncoding.base64().encode(val.getEncoded()));
     }
 
+    @Override
+    public Mapper<ReadObjectRequest.Builder> readObject() {
+      return b -> {
+        customerSuppliedKey(b.getCommonObjectRequestParamsBuilder(), val);
+        return b;
+      };
+    }
+
     @Override
     public Mapper<GetObjectRequest.Builder> getObject() {
-      return todo();
+      return b -> {
+        customerSuppliedKey(b.getCommonObjectRequestParamsBuilder(), val);
+        return b;
+      };
+    }
+
+    @Override
+    public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
+      return b -> {
+        CommonObjectRequestParams.Builder builder =
+            customerSuppliedKey(CommonObjectRequestParams.newBuilder(), val);
+        return b.setCopySourceEncryptionAlgorithm(builder.getEncryptionAlgorithm())
+            .setCopySourceEncryptionKeyBytes(builder.getEncryptionKeyBytes())
+            .setCopySourceEncryptionKeySha256Bytes(builder.getEncryptionKeySha256Bytes());
+      };
     }
   }
 
@@ -436,6 +570,11 @@ final class UnifiedOpts {
     private Delimiter(String val) {
       super(StorageRpc.Option.DELIMITER, val);
     }
+
+    @Override
+    public Mapper<ListObjectsRequest.Builder> listObjects() {
+      return b -> b.setDelimiter(val);
+    }
   }
 
   static final class DisableGzipContent extends RpcOptVal<@NonNull Boolean>
@@ -462,6 +601,46 @@ final class UnifiedOpts {
               StorageRpc.Option.CUSTOMER_SUPPLIED_KEY,
               BaseEncoding.base64().encode(val.getEncoded()));
     }
+
+    @Override
+    public Mapper<WriteObjectRequest.Builder> writeObject() {
+      return b -> {
+        customerSuppliedKey(b.getCommonObjectRequestParamsBuilder(), val);
+        return b;
+      };
+    }
+
+    @Override
+    public Mapper<UpdateObjectRequest.Builder> updateObject() {
+      return b -> {
+        customerSuppliedKey(b.getCommonObjectRequestParamsBuilder(), val);
+        return b;
+      };
+    }
+
+    @Override
+    public Mapper<DeleteObjectRequest.Builder> deleteObject() {
+      return b -> {
+        customerSuppliedKey(b.getCommonObjectRequestParamsBuilder(), val);
+        return b;
+      };
+    }
+
+    @Override
+    public Mapper<ComposeObjectRequest.Builder> composeObject() {
+      return b -> {
+        customerSuppliedKey(b.getCommonObjectRequestParamsBuilder(), val);
+        return b;
+      };
+    }
+
+    @Override
+    public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
+      return b -> {
+        customerSuppliedKey(b.getCommonObjectRequestParamsBuilder(), val);
+        return b;
+      };
+    }
   }
 
   /** @see StartOffset */
@@ -471,6 +650,11 @@ final class UnifiedOpts {
     private EndOffset(String val) {
       super(StorageRpc.Option.END_OFF_SET, val);
     }
+
+    @Override
+    public Mapper<ListObjectsRequest.Builder> listObjects() {
+      return b -> b.setLexicographicEnd(val);
+    }
   }
 
   static final class Fields extends RpcOptVal<String>
@@ -480,11 +664,6 @@ final class UnifiedOpts {
     private Fields(String val) {
       super(StorageRpc.Option.FIELDS, val);
     }
-
-    @Override
-    public Mapper<GetObjectRequest.Builder> getObject() {
-      return Mapper.identity();
-    }
   }
 
   /**
@@ -499,11 +678,44 @@ final class UnifiedOpts {
       super(StorageRpc.Option.IF_GENERATION_MATCH, val);
     }
 
+    @Override
+    public Mapper<WriteObjectRequest.Builder> writeObject() {
+      return b -> {
+        b.getWriteObjectSpecBuilder().setIfGenerationMatch(val);
+        return b;
+      };
+    }
+
+    @Override
+    public Mapper<ReadObjectRequest.Builder> readObject() {
+      return b -> b.setIfGenerationMatch(val);
+    }
+
     @Override
     public Mapper<GetObjectRequest.Builder> getObject() {
       return b -> b.setIfGenerationMatch(val);
     }
 
+    @Override
+    public Mapper<UpdateObjectRequest.Builder> updateObject() {
+      return b -> b.setIfGenerationMatch(val);
+    }
+
+    @Override
+    public Mapper<DeleteObjectRequest.Builder> deleteObject() {
+      return b -> b.setIfGenerationMatch(val);
+    }
+
+    @Override
+    public Mapper<ComposeObjectRequest.Builder> composeObject() {
+      return b -> b.setIfGenerationMatch(val);
+    }
+
+    @Override
+    public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
+      return Mapper.identity();
+    }
+
     @Override
     public SourceGenerationMatch asSource() {
       return new SourceGenerationMatch(val);
@@ -522,11 +734,39 @@ final class UnifiedOpts {
       super(StorageRpc.Option.IF_GENERATION_NOT_MATCH, val);
     }
 
+    @Override
+    public Mapper<WriteObjectRequest.Builder> writeObject() {
+      return b -> {
+        b.getWriteObjectSpecBuilder().setIfGenerationNotMatch(val);
+        return b;
+      };
+    }
+
+    @Override
+    public Mapper<ReadObjectRequest.Builder> readObject() {
+      return b -> b.setIfGenerationNotMatch(val);
+    }
+
     @Override
     public Mapper<GetObjectRequest.Builder> getObject() {
       return b -> b.setIfGenerationNotMatch(val);
     }
 
+    @Override
+    public Mapper<UpdateObjectRequest.Builder> updateObject() {
+      return b -> b.setIfGenerationNotMatch(val);
+    }
+
+    @Override
+    public Mapper<DeleteObjectRequest.Builder> deleteObject() {
+      return b -> b.setIfGenerationNotMatch(val);
+    }
+
+    @Override
+    public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
+      return Mapper.identity();
+    }
+
     @Override
     public SourceGenerationNotMatch asSource() {
       return new SourceGenerationNotMatch(val);
@@ -539,6 +779,19 @@ final class UnifiedOpts {
     private KmsKeyName(String val) {
       super(StorageRpc.Option.KMS_KEY_NAME, val);
     }
+
+    @Override
+    public Mapper<WriteObjectRequest.Builder> writeObject() {
+      return b -> {
+        b.getWriteObjectSpecBuilder().getResourceBuilder().setKmsKey(val);
+        return b;
+      };
+    }
+
+    @Override
+    public Mapper<ComposeObjectRequest.Builder> composeObject() {
+      return b -> b.setKmsKey(val);
+    }
   }
 
   @Deprecated
@@ -594,11 +847,59 @@ final class UnifiedOpts {
       super(StorageRpc.Option.IF_METAGENERATION_MATCH, val);
     }
 
+    @Override
+    public Mapper<WriteObjectRequest.Builder> writeObject() {
+      return b -> {
+        b.getWriteObjectSpecBuilder().setIfMetagenerationMatch(val);
+        return b;
+      };
+    }
+
+    @Override
+    public Mapper<ReadObjectRequest.Builder> readObject() {
+      return b -> b.setIfMetagenerationMatch(val);
+    }
+
     @Override
     public Mapper<GetObjectRequest.Builder> getObject() {
       return b -> b.setIfMetagenerationMatch(val);
     }
 
+    @Override
+    public Mapper<UpdateObjectRequest.Builder> updateObject() {
+      return b -> b.setIfMetagenerationMatch(val);
+    }
+
+    @Override
+    public Mapper<DeleteObjectRequest.Builder> deleteObject() {
+      return b -> b.setIfMetagenerationMatch(val);
+    }
+
+    @Override
+    public Mapper<ComposeObjectRequest.Builder> composeObject() {
+      return b -> b.setIfMetagenerationMatch(val);
+    }
+
+    @Override
+    public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
+      return Mapper.identity();
+    }
+
+    @Override
+    public Mapper<UpdateBucketRequest.Builder> updateBucket() {
+      return b -> b.setIfMetagenerationMatch(val);
+    }
+
+    @Override
+    public Mapper<DeleteBucketRequest.Builder> deleteBucket() {
+      return b -> b.setIfMetagenerationMatch(val);
+    }
+
+    @Override
+    public Mapper<GetBucketRequest.Builder> getBucket() {
+      return b -> b.setIfMetagenerationMatch(val);
+    }
+
     @Override
     public SourceMetagenerationMatch asSource() {
       return new SourceMetagenerationMatch(val);
@@ -621,11 +922,54 @@ final class UnifiedOpts {
       super(StorageRpc.Option.IF_METAGENERATION_NOT_MATCH, val);
     }
 
+    @Override
+    public Mapper<WriteObjectRequest.Builder> writeObject() {
+      return b -> {
+        b.getWriteObjectSpecBuilder().setIfMetagenerationNotMatch(val);
+        return b;
+      };
+    }
+
+    @Override
+    public Mapper<ReadObjectRequest.Builder> readObject() {
+      return b -> b.setIfMetagenerationNotMatch(val);
+    }
+
     @Override
     public Mapper<GetObjectRequest.Builder> getObject() {
       return b -> b.setIfMetagenerationNotMatch(val);
     }
 
+    @Override
+    public Mapper<UpdateObjectRequest.Builder> updateObject() {
+      return b -> b.setIfMetagenerationNotMatch(val);
+    }
+
+    @Override
+    public Mapper<DeleteObjectRequest.Builder> deleteObject() {
+      return b -> b.setIfMetagenerationNotMatch(val);
+    }
+
+    @Override
+    public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
+      return Mapper.identity();
+    }
+
+    @Override
+    public Mapper<UpdateBucketRequest.Builder> updateBucket() {
+      return b -> b.setIfMetagenerationNotMatch(val);
+    }
+
+    @Override
+    public Mapper<DeleteBucketRequest.Builder> deleteBucket() {
+      return b -> b.setIfMetagenerationNotMatch(val);
+    }
+
+    @Override
+    public Mapper<GetBucketRequest.Builder> getBucket() {
+      return b -> b.setIfMetagenerationNotMatch(val);
+    }
+
     @Override
     public SourceMetagenerationNotMatch asSource() {
       return new SourceMetagenerationNotMatch(val);
@@ -639,6 +983,21 @@ final class UnifiedOpts {
     private PageSize(long val) {
       super(StorageRpc.Option.MAX_RESULTS, val);
     }
+
+    @Override
+    public Mapper<ListHmacKeysRequest.Builder> listHmacKeys() {
+      return b -> b.setPageSize(Math.toIntExact(val));
+    }
+
+    @Override
+    public Mapper<ListBucketsRequest.Builder> listBuckets() {
+      return b -> b.setPageSize(Math.toIntExact(val));
+    }
+
+    @Override
+    public Mapper<ListObjectsRequest.Builder> listObjects() {
+      return b -> b.setPageSize(Math.toIntExact(val));
+    }
   }
 
   static final class PageToken extends RpcOptVal<String>
@@ -648,6 +1007,21 @@ final class UnifiedOpts {
     private PageToken(String val) {
       super(StorageRpc.Option.PAGE_TOKEN, val);
     }
+
+    @Override
+    public Mapper<ListHmacKeysRequest.Builder> listHmacKeys() {
+      return b -> b.setPageToken(val);
+    }
+
+    @Override
+    public Mapper<ListBucketsRequest.Builder> listBuckets() {
+      return b -> b.setPageToken(val);
+    }
+
+    @Override
+    public Mapper<ListObjectsRequest.Builder> listObjects() {
+      return b -> b.setPageToken(val);
+    }
   }
 
   static final class PredefinedAcl extends RpcOptVal<String>
@@ -657,6 +1031,34 @@ final class UnifiedOpts {
     private PredefinedAcl(String val) {
       super(StorageRpc.Option.PREDEFINED_ACL, val);
     }
+
+    @Override
+    public Mapper<WriteObjectRequest.Builder> writeObject() {
+      return b -> {
+        b.getWriteObjectSpecBuilder().setPredefinedAcl(val);
+        return b;
+      };
+    }
+
+    @Override
+    public Mapper<UpdateObjectRequest.Builder> updateObject() {
+      return b -> b.setPredefinedAcl(val);
+    }
+
+    @Override
+    public Mapper<ComposeObjectRequest.Builder> composeObject() {
+      return b -> b.setDestinationPredefinedAcl(val);
+    }
+
+    @Override
+    public Mapper<UpdateBucketRequest.Builder> updateBucket() {
+      return b -> b.setPredefinedAcl(val);
+    }
+
+    @Override
+    public Mapper<CreateBucketRequest.Builder> createBucket() {
+      return b -> b.setPredefinedAcl(val);
+    }
   }
 
   static final class PredefinedDefaultObjectAcl extends RpcOptVal<String>
@@ -666,6 +1068,16 @@ final class UnifiedOpts {
     private PredefinedDefaultObjectAcl(String val) {
       super(StorageRpc.Option.PREDEFINED_DEFAULT_OBJECT_ACL, val);
     }
+
+    @Override
+    public Mapper<CreateBucketRequest.Builder> createBucket() {
+      return b -> b.setPredefinedDefaultObjectAcl(val);
+    }
+
+    @Override
+    public Mapper<UpdateBucketRequest.Builder> updateBucket() {
+      return b -> b.setPredefinedDefaultObjectAcl(val);
+    }
   }
 
   static final class Prefix extends RpcOptVal<String> implements BucketListOpt, ObjectListOpt {
@@ -674,6 +1086,16 @@ final class UnifiedOpts {
     private Prefix(String val) {
       super(StorageRpc.Option.PREFIX, val);
     }
+
+    @Override
+    public Mapper<ListBucketsRequest.Builder> listBuckets() {
+      return b -> b.setPrefix(val);
+    }
+
+    @Override
+    public Mapper<ListObjectsRequest.Builder> listObjects() {
+      return b -> b.setPrefix(val);
+    }
   }
 
   /**
@@ -688,6 +1110,21 @@ final class UnifiedOpts {
     private ProjectId(String val) {
       super(StorageRpc.Option.PROJECT_ID, val);
     }
+
+    @Override
+    public Mapper<ListHmacKeysRequest.Builder> listHmacKeys() {
+      return b -> b.setProject(projectNameCodec.encode(val));
+    }
+
+    @Override
+    public Mapper<GetHmacKeyRequest.Builder> getHmacKey() {
+      return b -> b.setProject(projectNameCodec.encode(val));
+    }
+
+    @Override
+    public Mapper<DeleteHmacKeyRequest.Builder> deleteHmacKey() {
+      return b -> b.setProject(projectNameCodec.encode(val));
+    }
   }
 
   static final class Projection extends RpcOptVal<String> implements BucketTargetOpt {
@@ -711,8 +1148,8 @@ final class UnifiedOpts {
     }
 
     @Override
-    public Mapper<GetObjectRequest.Builder> getObject() {
-      return Mapper.identity();
+    public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
+      return b -> b.setIfSourceGenerationMatch(val);
     }
   }
 
@@ -729,8 +1166,8 @@ final class UnifiedOpts {
     }
 
     @Override
-    public Mapper<GetObjectRequest.Builder> getObject() {
-      return Mapper.identity();
+    public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
+      return b -> b.setIfSourceGenerationNotMatch(val);
     }
   }
 
@@ -747,8 +1184,8 @@ final class UnifiedOpts {
     }
 
     @Override
-    public Mapper<GetObjectRequest.Builder> getObject() {
-      return Mapper.identity();
+    public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
+      return b -> b.setIfSourceMetagenerationMatch(val);
     }
   }
 
@@ -765,8 +1202,8 @@ final class UnifiedOpts {
     }
 
     @Override
-    public Mapper<GetObjectRequest.Builder> getObject() {
-      return Mapper.identity();
+    public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
+      return b -> b.setIfSourceMetagenerationNotMatch(val);
     }
   }
 
@@ -777,6 +1214,14 @@ final class UnifiedOpts {
     private RequestedPolicyVersion(Long val) {
       super(StorageRpc.Option.REQUESTED_POLICY_VERSION, val);
     }
+
+    @Override
+    public Mapper<GetIamPolicyRequest.Builder> getIamPolicy() {
+      return b -> {
+        b.getOptionsBuilder().setRequestedPolicyVersion(Math.toIntExact(val));
+        return b;
+      };
+    }
   }
 
   static final class ReturnRawInputStream extends RpcOptVal<@NonNull Boolean>
@@ -799,6 +1244,11 @@ final class UnifiedOpts {
     private ServiceAccount(String val) {
       super(StorageRpc.Option.SERVICE_ACCOUNT_EMAIL, val);
     }
+
+    @Override
+    public Mapper<ListHmacKeysRequest.Builder> listHmacKeys() {
+      return b -> b.setServiceAccountEmail(val);
+    }
   }
 
   static final class SetContentType implements ObjectTargetOpt {
@@ -814,6 +1264,22 @@ final class UnifiedOpts {
       return b -> b.setContentType(val);
     }
 
+    @Override
+    public Mapper<WriteObjectRequest.Builder> writeObject() {
+      return b -> {
+        b.getWriteObjectSpecBuilder().getResourceBuilder().setContentType(val);
+        return b;
+      };
+    }
+
+    @Override
+    public Mapper<UpdateObjectRequest.Builder> updateObject() {
+      return b -> {
+        b.getObjectBuilder().setContentType(val);
+        return b;
+      };
+    }
+
     @Override
     public boolean equals(Object o) {
       if (this == o) {
@@ -843,6 +1309,11 @@ final class UnifiedOpts {
     private ShowDeletedKeys(boolean val) {
       super(StorageRpc.Option.SHOW_DELETED_KEYS, val);
     }
+
+    @Override
+    public Mapper<ListHmacKeysRequest.Builder> listHmacKeys() {
+      return b -> b.setShowDeletedKeys(val);
+    }
   }
 
   /** @see EndOffset */
@@ -852,6 +1323,11 @@ final class UnifiedOpts {
     private StartOffset(String val) {
       super(StorageRpc.Option.START_OFF_SET, val);
     }
+
+    @Override
+    public Mapper<ListObjectsRequest.Builder> listObjects() {
+      return b -> b.setLexicographicStart(val);
+    }
   }
 
   static final class UserProject extends RpcOptVal<String>
@@ -877,7 +1353,7 @@ final class UnifiedOpts {
     }
 
     @Override
-    public Mapper<GetObjectRequest.Builder> getObject() {
+    public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
       return Mapper.identity();
     }
   }
@@ -886,6 +1362,11 @@ final class UnifiedOpts {
     private VersionsFilter(boolean val) {
       super(StorageRpc.Option.VERSIONS, val);
     }
+
+    @Override
+    public Mapper<ListObjectsRequest.Builder> listObjects() {
+      return b -> b.setVersions(val);
+    }
   }
 
   /**
@@ -1384,14 +1865,104 @@ final class UnifiedOpts {
       return fuseMappers(GrpcMetadataMapper.class, GrpcMetadataMapper::getGrpcMetadataMapper);
     }
 
-    Mapper<GetObjectRequest.Builder> getObjectRequest() {
+    Mapper<CreateBucketRequest.Builder> createBucketsRequest() {
+      return fuseMappers(BucketTargetOpt.class, BucketTargetOpt::createBucket);
+    }
+
+    Mapper<GetBucketRequest.Builder> getBucketsRequest() {
+      return fuseMappers(BucketSourceOpt.class, BucketSourceOpt::getBucket);
+    }
+
+    Mapper<ListBucketsRequest.Builder> listBucketsRequest() {
+      return fuseMappers(BucketListOpt.class, BucketListOpt::listBuckets);
+    }
+
+    Mapper<UpdateBucketRequest.Builder> updateBucketsRequest() {
+      return fuseMappers(BucketTargetOpt.class, BucketTargetOpt::updateBucket);
+    }
+
+    Mapper<DeleteBucketRequest.Builder> deleteBucketsRequest() {
+      return fuseMappers(BucketTargetOpt.class, BucketTargetOpt::deleteBucket);
+    }
+
+    Mapper<WriteObjectRequest.Builder> writeObjectRequest() {
+      return fuseMappers(ObjectTargetOpt.class, ObjectTargetOpt::writeObject);
+    }
+
+    Mapper<GetObjectRequest.Builder> getObjectsRequest() {
       return fuseMappers(ObjectSourceOpt.class, ObjectSourceOpt::getObject);
     }
 
+    Mapper<ReadObjectRequest.Builder> readObjectRequest() {
+      return fuseMappers(ObjectSourceOpt.class, ObjectSourceOpt::readObject);
+    }
+
+    Mapper<ListObjectsRequest.Builder> listObjectsRequest() {
+      return fuseMappers(ObjectListOpt.class, ObjectListOpt::listObjects);
+    }
+
+    Mapper<UpdateObjectRequest.Builder> updateObjectsRequest() {
+      return fuseMappers(ObjectTargetOpt.class, ObjectTargetOpt::updateObject);
+    }
+
+    Mapper<DeleteObjectRequest.Builder> deleteObjectsRequest() {
+      return fuseMappers(ObjectTargetOpt.class, ObjectTargetOpt::deleteObject);
+    }
+
+    Mapper<ComposeObjectRequest.Builder> composeObjectsRequest() {
+      return fuseMappers(ObjectTargetOpt.class, ObjectTargetOpt::composeObject);
+    }
+
+    Mapper<RewriteObjectRequest.Builder> rewriteObjectsRequest() {
+      return opts.stream()
+          .filter(isInstanceOf(ObjectTargetOpt.class).or(isInstanceOf(ObjectSourceOpt.class)))
+          .map(
+              o -> {
+                // TODO: Do we need to formalize this type of dual relationship with it's own
+                // interface?
+                if (o instanceof ObjectTargetOpt) {
+                  ObjectTargetOpt oto = (ObjectTargetOpt) o;
+                  return oto.rewriteObject();
+                } else if (o instanceof ObjectSourceOpt) {
+                  ObjectSourceOpt oso = (ObjectSourceOpt) o;
+                  return oso.rewriteObject();
+                } else {
+                  // in practice this shouldn't happen because of the filter guard upstream
+                  throw new IllegalStateException("Unexpected type: %s" + o.getClass());
+                }
+              })
+          .reduce(Mapper.identity(), Mapper::andThen);
+    }
+
+    Mapper<CreateHmacKeyRequest.Builder> createHmacKeysRequest() {
+      return fuseMappers(HmacKeyTargetOpt.class, HmacKeyTargetOpt::createHmacKey);
+    }
+
+    Mapper<GetHmacKeyRequest.Builder> getHmacKeysRequest() {
+      return fuseMappers(HmacKeySourceOpt.class, HmacKeySourceOpt::getHmacKey);
+    }
+
+    Mapper<ListHmacKeysRequest.Builder> listHmacKeysRequest() {
+      return fuseMappers(HmacKeyListOpt.class, HmacKeyListOpt::listHmacKeys);
+    }
+
+    Mapper<UpdateHmacKeyRequest.Builder> updateHmacKeysRequest() {
+      return fuseMappers(HmacKeyTargetOpt.class, HmacKeyTargetOpt::updateHmacKey);
+    }
+
+    Mapper<DeleteHmacKeyRequest.Builder> deleteHmacKeysRequest() {
+      return fuseMappers(HmacKeyTargetOpt.class, HmacKeyTargetOpt::deleteHmacKey);
+    }
+
     Mapper<BlobInfo.Builder> blobInfoMapper() {
       return fuseMappers(ObjectTargetOpt.class, ObjectTargetOpt::blobInfo);
     }
 
+    @Deprecated
+    Optional<ProjectId> projectId() {
+      return filterTo(ProjectId.class).findFirst();
+    }
+
     private Mapper<ImmutableMap.Builder<StorageRpc.Option, Object>> rpcOptionMapper() {
       return fuseMappers(RpcOptVal.class, RpcOptVal::mapper);
     }
@@ -1406,6 +1977,10 @@ final class UnifiedOpts {
       return opts.stream().filter(isInstanceOf(c)).map(x -> (R) x);
     }
 
+    static <T extends Opt> Opts<T> from(T t) {
+      return new Opts<>(ImmutableList.of(t));
+    }
+
     static <T extends Opt> Opts<T> from(T... ts) {
       return new Opts<>(ImmutableList.copyOf(ts));
     }
@@ -1435,4 +2010,12 @@ final class UnifiedOpts {
       return t -> c.isAssignableFrom(t.getClass());
     }
   }
+
+  private static CommonObjectRequestParams.Builder customerSuppliedKey(
+      CommonObjectRequestParams.Builder b, Key key) {
+    HashCode keySha256 = Hashing.sha256().hashBytes(key.getEncoded());
+    return b.setEncryptionAlgorithm(key.getAlgorithm())
+        .setEncryptionKeyBytes(ByteString.copyFrom(key.getEncoded()))
+        .setEncryptionKeySha256Bytes(ByteString.copyFrom(keySha256.asBytes()));
+  }
 }
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 e3a09e79..d32e5232 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
@@ -24,6 +24,7 @@ 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 com.google.storage.v2.ProjectName;
 import java.time.Duration;
 import java.time.Instant;
 import java.time.OffsetDateTime;
@@ -122,6 +123,30 @@ final class Utils {
             }
           });
 
+  /**
+   * Define a Codec which encapsulates the logic necessary to handle encoding and decoding project
+   * names.
+   */
+  static final Codec<String, String> projectNameCodec =
+      Codec.of(
+          project -> {
+            requireNonNull(project, "project must be non null");
+            if (project.startsWith("projects/")) {
+              return project;
+            } else {
+              return "projects/" + project;
+            }
+          },
+          resourceName -> {
+            requireNonNull(resourceName, "resourceName must be non null");
+            if (ProjectName.isParsableFrom(resourceName)) {
+              ProjectName parse = ProjectName.parse(resourceName);
+              return parse.getProject();
+            } else {
+              return resourceName;
+            }
+          });
+
   private Utils() {}
 
   /**
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ZOpt.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ZOpt.java
deleted file mode 100644
index 6c77c2dc..00000000
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/ZOpt.java
+++ /dev/null
@@ -1,181 +0,0 @@
-/*
- * Copyright 2022 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.cloud.storage.spi.v1.StorageRpc;
-import java.util.Map;
-import java.util.function.Consumer;
-import java.util.function.Function;
-import org.checkerframework.checker.nullness.qual.NonNull;
-import org.checkerframework.checker.nullness.qual.Nullable;
-
-/**
- * A new helper abstraction which makes working with {@link StorageRpc.Option}s more type safe, and
- * null safe. When working with a {@code Map<StorageRpc.Option, ?>} we loose the independent type
- * information for each key. The type conversion methods on StorageRpc.Option are package-private,
- * and we DO NOT want to make them public. Which leads to this new class, which essentially creates
- * a mapping between a java type and a specific {@code StorageRpc.Option}.
- *
- * <p>We are defining each Option as a constant rather than an enum, because enum's can't have
- * generic class parameters. This shouldn't pose an issue:
- *
- * <ol>
- *   <li>This class is package-private
- *   <li>This class is sealed, and can only be constructed from within it's definition
- *   <li>This class is strictly used for mapping, never a property in a model that could be
- *       serialized
- * </ol>
- */
-final class ZOpt<T> {
-
-  static final ZOpt<String> PREDEFINED_ACL = new ZOpt<>(StorageRpc.Option.PREDEFINED_ACL);
-  static final ZOpt<String> PREDEFINED_DEFAULT_OBJECT_ACL =
-      new ZOpt<>(StorageRpc.Option.PREDEFINED_DEFAULT_OBJECT_ACL);
-  static final ZOpt<Long> IF_METAGENERATION_MATCH =
-      new ZOpt<>(StorageRpc.Option.IF_METAGENERATION_MATCH);
-  static final ZOpt<Long> IF_METAGENERATION_NOT_MATCH =
-      new ZOpt<>(StorageRpc.Option.IF_METAGENERATION_NOT_MATCH);
-  static final ZOpt<Long> IF_GENERATION_MATCH = new ZOpt<>(StorageRpc.Option.IF_GENERATION_MATCH);
-  static final ZOpt<Long> IF_GENERATION_NOT_MATCH =
-      new ZOpt<>(StorageRpc.Option.IF_GENERATION_NOT_MATCH);
-  static final ZOpt<Long> IF_SOURCE_METAGENERATION_MATCH =
-      new ZOpt<>(StorageRpc.Option.IF_SOURCE_METAGENERATION_MATCH);
-  static final ZOpt<Long> IF_SOURCE_METAGENERATION_NOT_MATCH =
-      new ZOpt<>(StorageRpc.Option.IF_SOURCE_METAGENERATION_NOT_MATCH);
-  static final ZOpt<Long> IF_SOURCE_GENERATION_MATCH =
-      new ZOpt<>(StorageRpc.Option.IF_SOURCE_GENERATION_MATCH);
-  static final ZOpt<Long> IF_SOURCE_GENERATION_NOT_MATCH =
-      new ZOpt<>(StorageRpc.Option.IF_SOURCE_GENERATION_NOT_MATCH);
-  static final ZOpt<Boolean> IF_DISABLE_GZIP_CONTENT =
-      new ZOpt<>(StorageRpc.Option.IF_DISABLE_GZIP_CONTENT);
-  static final ZOpt<String> PREFIX = new ZOpt<>(StorageRpc.Option.PREFIX);
-  static final ZOpt<String> PROJECT_ID = new ZOpt<>(StorageRpc.Option.PROJECT_ID);
-  static final ZOpt<String> PROJECTION = new ZOpt<>(StorageRpc.Option.PROJECTION);
-  static final ZOpt<Long> MAX_RESULTS = new ZOpt<>(StorageRpc.Option.MAX_RESULTS);
-  static final ZOpt<String> PAGE_TOKEN = new ZOpt<>(StorageRpc.Option.PAGE_TOKEN);
-  static final ZOpt<String> DELIMITER = new ZOpt<>(StorageRpc.Option.DELIMITER);
-  static final ZOpt<String> START_OFF_SET = new ZOpt<>(StorageRpc.Option.START_OFF_SET);
-  static final ZOpt<String> END_OFF_SET = new ZOpt<>(StorageRpc.Option.END_OFF_SET);
-  static final ZOpt<Boolean> VERSIONS = new ZOpt<>(StorageRpc.Option.VERSIONS);
-  static final ZOpt<String> FIELDS = new ZOpt<>(StorageRpc.Option.FIELDS);
-  static final ZOpt<String> CUSTOMER_SUPPLIED_KEY =
-      new ZOpt<>(StorageRpc.Option.CUSTOMER_SUPPLIED_KEY);
-  static final ZOpt<String> USER_PROJECT = new ZOpt<>(StorageRpc.Option.USER_PROJECT);
-  static final ZOpt<String> KMS_KEY_NAME = new ZOpt<>(StorageRpc.Option.KMS_KEY_NAME);
-  static final ZOpt<String> SERVICE_ACCOUNT_EMAIL =
-      new ZOpt<>(StorageRpc.Option.SERVICE_ACCOUNT_EMAIL);
-  static final ZOpt<Boolean> SHOW_DELETED_KEYS = new ZOpt<>(StorageRpc.Option.SHOW_DELETED_KEYS);
-  static final ZOpt<Long> REQUESTED_POLICY_VERSION =
-      new ZOpt<>(StorageRpc.Option.REQUESTED_POLICY_VERSION);
-  static final ZOpt<String> DETECT_CONTENT_TYPE = new ZOpt<>(StorageRpc.Option.DETECT_CONTENT_TYPE);
-  static final ZOpt<Boolean> RETURN_RAW_INPUT_STREAM =
-      new ZOpt<>(StorageRpc.Option.RETURN_RAW_INPUT_STREAM);
-
-  private final StorageRpc.Option key;
-
-  private ZOpt(StorageRpc.Option key) {
-    this.key = key;
-  }
-
-  StorageRpc.Option getKey() {
-    return key;
-  }
-
-  /**
-   * This method is only ever called from package private scope, as such the Map of option values
-   * has prior type checked values which are later erased. Here we suppress the unchecked warning
-   * since it's guarded ealier in the conversion.
-   */
-  @SuppressWarnings("unchecked")
-  T cast(Object o) {
-    return (T) o;
-  }
-
-  /**
-   * Attempt to get the typed value from {@code optionValues}. If the key is not present in the
-   * provided {@code optionValues} null will be returned.
-   */
-  @Nullable
-  T get(@NonNull Map<StorageRpc.Option, ?> optionValues) {
-    Object o = optionValues.get(key);
-    return o == null ? null : cast(o);
-  }
-
-  /**
-   * Create a new {@link ZOptConsumer} bound to the provided {@code consumer}
-   *
-   * @see ZOptConsumer#apply(Map)
-   */
-  ZOptConsumer<T, T> consumeVia(Consumer<@NonNull T> consumer) {
-    return new ZOptConsumer<>(this, Function.identity(), consumer);
-  }
-
-  /**
-   * Create a new {@link ZOptConsumer} which will apply {@code mapper} before consumption.
-   *
-   * @see ZOptConsumer#apply(Map)
-   */
-  <X> ZOptConsumer<T, X> mapThenConsumeVia(Function<T, X> mapper, Consumer<@NonNull X> consumer) {
-    return new ZOptConsumer<>(this, mapper, consumer);
-  }
-
-  /**
-   * For each consumer in {@code consumers} invoke {@link ZOptConsumer#apply(Map)} with {@code
-   * optionValues}
-   */
-  static void applyAll(Map<StorageRpc.Option, ?> optionValues, ZOptConsumer<?, ?>... consumers) {
-    for (ZOptConsumer<?, ?> consumer : consumers) {
-      consumer.apply(optionValues);
-    }
-  }
-
-  static class ZOptConsumer<T, X> {
-    private final ZOpt<T> opt;
-    private final Function<@NonNull T, @Nullable X> mapper;
-    private final Consumer<@Nullable X> consumer;
-
-    private ZOptConsumer(
-        ZOpt<T> opt, Function<@NonNull T, @Nullable X> mapper, Consumer<@NonNull X> consumer) {
-      this.opt = opt;
-      this.mapper = mapper;
-      this.consumer = consumer;
-    }
-
-    /**
-     * Apply this consumer to the provided {@code optionValues}.
-     *
-     * <p>A null value will never be passed to the {@code consumer}.
-     *
-     * <ul>
-     *   <li>If the provided {@code optionValues} does not contain a value related to {@code
-     *       this.opt.key}, the method will terminate.
-     *   <li>If the provided {@code optionValues} does contains a value, it will be sent through
-     *       {@code this.mapper} if the result is non-null it will be passed to {@code
-     *       this.consumer} otherwise the method will terminate.
-     * </ul>
-     */
-    void apply(Map<StorageRpc.Option, ?> optionValues) {
-      T t = opt.get(optionValues);
-      if (t != null) {
-        X x = mapper.apply(t);
-        if (x != null) {
-          consumer.accept(x);
-        }
-      }
-    }
-  }
-}


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/UnifiedOptsGrpcTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/UnifiedOptsGrpcTest.java
new file mode 100644
index 00000000..8099fe36
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/UnifiedOptsGrpcTest.java
@@ -0,0 +1,1377 @@
+/*
+ * Copyright 2022 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 static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.api.gax.grpc.GrpcCallContext;
+import com.google.cloud.storage.Storage.PredefinedAcl;
+import com.google.cloud.storage.UnifiedOpts.Mapper;
+import com.google.cloud.storage.UnifiedOpts.NoOpObjectTargetOpt;
+import com.google.cloud.storage.UnifiedOpts.ObjectTargetOpt;
+import com.google.cloud.storage.UnifiedOpts.Opts;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.hash.Hashing;
+import com.google.iam.v1.GetIamPolicyRequest;
+import com.google.iam.v1.GetPolicyOptions;
+import com.google.protobuf.ByteString;
+import com.google.storage.v2.CommonObjectRequestParams;
+import com.google.storage.v2.ComposeObjectRequest;
+import com.google.storage.v2.CreateBucketRequest;
+import com.google.storage.v2.DeleteBucketRequest;
+import com.google.storage.v2.DeleteHmacKeyRequest;
+import com.google.storage.v2.DeleteObjectRequest;
+import com.google.storage.v2.GetBucketRequest;
+import com.google.storage.v2.GetHmacKeyRequest;
+import com.google.storage.v2.GetObjectRequest;
+import com.google.storage.v2.ListBucketsRequest;
+import com.google.storage.v2.ListHmacKeysRequest;
+import com.google.storage.v2.ListObjectsRequest;
+import com.google.storage.v2.Object;
+import com.google.storage.v2.ReadObjectRequest;
+import com.google.storage.v2.RewriteObjectRequest;
+import com.google.storage.v2.UpdateBucketRequest;
+import com.google.storage.v2.UpdateObjectRequest;
+import com.google.storage.v2.WriteObjectRequest;
+import com.google.storage.v2.WriteObjectSpec;
+import java.security.SecureRandom;
+import javax.crypto.spec.SecretKeySpec;
+import org.junit.Test;
+import org.junit.experimental.runners.Enclosed;
+import org.junit.runner.RunWith;
+
+@RunWith(Enclosed.class)
+public final class UnifiedOptsGrpcTest {
+
+  @RunWith(Enclosed.class)
+  public static final class Opt {
+
+    public static final class DecryptionKeyTest {
+      @Test
+      public void readObject() {
+        CommonObjectRequestParams rand = randEncryption();
+        SecretKeySpec k =
+            new SecretKeySpec(
+                rand.getEncryptionKeyBytes().toByteArray(), rand.getEncryptionAlgorithm());
+
+        ReadObjectRequest expected =
+            ReadObjectRequest.newBuilder().setCommonObjectRequestParams(rand).build();
+        ReadObjectRequest actual =
+            UnifiedOpts.decryptionKey(k).readObject().apply(ReadObjectRequest.newBuilder()).build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void getObject() {
+        CommonObjectRequestParams rand = randEncryption();
+        SecretKeySpec k =
+            new SecretKeySpec(
+                rand.getEncryptionKeyBytes().toByteArray(), rand.getEncryptionAlgorithm());
+
+        GetObjectRequest expected =
+            GetObjectRequest.newBuilder().setCommonObjectRequestParams(rand).build();
+        GetObjectRequest actual =
+            UnifiedOpts.decryptionKey(k).getObject().apply(GetObjectRequest.newBuilder()).build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void rewriteObject() {
+        CommonObjectRequestParams rand = randEncryption();
+        SecretKeySpec k =
+            new SecretKeySpec(
+                rand.getEncryptionKeyBytes().toByteArray(), rand.getEncryptionAlgorithm());
+
+        RewriteObjectRequest expected =
+            RewriteObjectRequest.newBuilder()
+                .setCopySourceEncryptionAlgorithm(rand.getEncryptionAlgorithm())
+                .setCopySourceEncryptionKeyBytes(rand.getEncryptionKeyBytes())
+                .setCopySourceEncryptionKeySha256Bytes(rand.getEncryptionKeySha256Bytes())
+                .build();
+        RewriteObjectRequest actual =
+            UnifiedOpts.decryptionKey(k)
+                .rewriteObject()
+                .apply(RewriteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class DelimiterTest {
+      @Test
+      public void listObjects() {
+        ListObjectsRequest expected = ListObjectsRequest.newBuilder().setDelimiter("|~|").build();
+
+        ListObjectsRequest actual =
+            UnifiedOpts.delimiter("|~|")
+                .listObjects()
+                .apply(ListObjectsRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class EncryptionKeyTest {
+      @Test
+      public void writeObject() {
+        CommonObjectRequestParams rand = randEncryption();
+        SecretKeySpec k =
+            new SecretKeySpec(
+                rand.getEncryptionKeyBytes().toByteArray(), rand.getEncryptionAlgorithm());
+
+        WriteObjectRequest expected =
+            WriteObjectRequest.newBuilder().setCommonObjectRequestParams(rand).build();
+        WriteObjectRequest actual =
+            UnifiedOpts.encryptionKey(k)
+                .writeObject()
+                .apply(WriteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void updateObject() {
+        CommonObjectRequestParams rand = randEncryption();
+        SecretKeySpec k =
+            new SecretKeySpec(
+                rand.getEncryptionKeyBytes().toByteArray(), rand.getEncryptionAlgorithm());
+
+        UpdateObjectRequest expected =
+            UpdateObjectRequest.newBuilder().setCommonObjectRequestParams(rand).build();
+        UpdateObjectRequest actual =
+            UnifiedOpts.encryptionKey(k)
+                .updateObject()
+                .apply(UpdateObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void deleteObject() {
+        CommonObjectRequestParams rand = randEncryption();
+        SecretKeySpec k =
+            new SecretKeySpec(
+                rand.getEncryptionKeyBytes().toByteArray(), rand.getEncryptionAlgorithm());
+
+        DeleteObjectRequest expected =
+            DeleteObjectRequest.newBuilder().setCommonObjectRequestParams(rand).build();
+        DeleteObjectRequest actual =
+            UnifiedOpts.encryptionKey(k)
+                .deleteObject()
+                .apply(DeleteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void composeObject() {
+        CommonObjectRequestParams rand = randEncryption();
+        SecretKeySpec k =
+            new SecretKeySpec(
+                rand.getEncryptionKeyBytes().toByteArray(), rand.getEncryptionAlgorithm());
+
+        ComposeObjectRequest expected =
+            ComposeObjectRequest.newBuilder().setCommonObjectRequestParams(rand).build();
+        ComposeObjectRequest actual =
+            UnifiedOpts.encryptionKey(k)
+                .composeObject()
+                .apply(ComposeObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void rewriteObject() {
+        CommonObjectRequestParams rand = randEncryption();
+        SecretKeySpec k =
+            new SecretKeySpec(
+                rand.getEncryptionKeyBytes().toByteArray(), rand.getEncryptionAlgorithm());
+
+        RewriteObjectRequest expected =
+            RewriteObjectRequest.newBuilder().setCommonObjectRequestParams(rand).build();
+        RewriteObjectRequest actual =
+            UnifiedOpts.encryptionKey(k)
+                .rewriteObject()
+                .apply(RewriteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class EndOffsetTest {
+      @Test
+      public void listObjects() {
+        ListObjectsRequest expected =
+            ListObjectsRequest.newBuilder().setLexicographicStart("start").build();
+
+        ListObjectsRequest actual =
+            UnifiedOpts.startOffset("start")
+                .listObjects()
+                .apply(ListObjectsRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class GenerationMatchTest {
+      @Test
+      public void writeObject() {
+        WriteObjectRequest expected =
+            WriteObjectRequest.newBuilder()
+                .setWriteObjectSpec(WriteObjectSpec.newBuilder().setIfGenerationMatch(1L))
+                .build();
+        WriteObjectRequest actual =
+            UnifiedOpts.generationMatch(1L)
+                .writeObject()
+                .apply(WriteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void readObject() {
+        ReadObjectRequest expected =
+            ReadObjectRequest.newBuilder().setIfGenerationMatch(1L).build();
+        ReadObjectRequest actual =
+            UnifiedOpts.generationMatch(1L)
+                .readObject()
+                .apply(ReadObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void getObject() {
+        GetObjectRequest expected = GetObjectRequest.newBuilder().setIfGenerationMatch(1L).build();
+        GetObjectRequest actual =
+            UnifiedOpts.generationMatch(1L)
+                .getObject()
+                .apply(GetObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void updateObject() {
+        UpdateObjectRequest expected =
+            UpdateObjectRequest.newBuilder().setIfGenerationMatch(1L).build();
+        UpdateObjectRequest actual =
+            UnifiedOpts.generationMatch(1L)
+                .updateObject()
+                .apply(UpdateObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void deleteObject() {
+        DeleteObjectRequest expected =
+            DeleteObjectRequest.newBuilder().setIfGenerationMatch(1L).build();
+        DeleteObjectRequest actual =
+            UnifiedOpts.generationMatch(1L)
+                .deleteObject()
+                .apply(DeleteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void composeObject() {
+        ComposeObjectRequest expected =
+            ComposeObjectRequest.newBuilder().setIfGenerationMatch(1L).build();
+        ComposeObjectRequest actual =
+            UnifiedOpts.generationMatch(1L)
+                .composeObject()
+                .apply(ComposeObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class GenerationNotMatchTest {
+      @Test
+      public void writeObject() {
+        WriteObjectRequest expected =
+            WriteObjectRequest.newBuilder()
+                .setWriteObjectSpec(WriteObjectSpec.newBuilder().setIfGenerationNotMatch(1L))
+                .build();
+        WriteObjectRequest actual =
+            UnifiedOpts.generationNotMatch(1L)
+                .writeObject()
+                .apply(WriteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void readObject() {
+        ReadObjectRequest expected =
+            ReadObjectRequest.newBuilder().setIfGenerationNotMatch(1L).build();
+        ReadObjectRequest actual =
+            UnifiedOpts.generationNotMatch(1L)
+                .readObject()
+                .apply(ReadObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void getObject() {
+        GetObjectRequest expected =
+            GetObjectRequest.newBuilder().setIfGenerationNotMatch(1L).build();
+        GetObjectRequest actual =
+            UnifiedOpts.generationNotMatch(1L)
+                .getObject()
+                .apply(GetObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void updateObject() {
+        UpdateObjectRequest expected =
+            UpdateObjectRequest.newBuilder().setIfGenerationNotMatch(1L).build();
+        UpdateObjectRequest actual =
+            UnifiedOpts.generationNotMatch(1L)
+                .updateObject()
+                .apply(UpdateObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void deleteObject() {
+        DeleteObjectRequest expected =
+            DeleteObjectRequest.newBuilder().setIfGenerationNotMatch(1L).build();
+        DeleteObjectRequest actual =
+            UnifiedOpts.generationNotMatch(1L)
+                .deleteObject()
+                .apply(DeleteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class KmsKeyNameTest {
+      @Test
+      public void writeObject() {
+        WriteObjectRequest expected =
+            WriteObjectRequest.newBuilder()
+                .setWriteObjectSpec(
+                    WriteObjectSpec.newBuilder()
+                        .setResource(Object.newBuilder().setKmsKey("key").build())
+                        .build())
+                .build();
+
+        WriteObjectRequest actual =
+            UnifiedOpts.kmsKeyName("key")
+                .writeObject()
+                .apply(WriteObjectRequest.newBuilder())
+                .build();
+
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void composeObject() {
+        ComposeObjectRequest expected = ComposeObjectRequest.newBuilder().setKmsKey("key").build();
+
+        ComposeObjectRequest actual =
+            UnifiedOpts.kmsKeyName("key")
+                .composeObject()
+                .apply(ComposeObjectRequest.newBuilder())
+                .build();
+
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class MetagenerationMatchTest {
+      @Test
+      public void writeObject() {
+        WriteObjectRequest expected =
+            WriteObjectRequest.newBuilder()
+                .setWriteObjectSpec(WriteObjectSpec.newBuilder().setIfMetagenerationMatch(1L))
+                .build();
+        WriteObjectRequest actual =
+            UnifiedOpts.metagenerationMatch(1L)
+                .writeObject()
+                .apply(WriteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void readObject() {
+        ReadObjectRequest expected =
+            ReadObjectRequest.newBuilder().setIfMetagenerationMatch(1L).build();
+        ReadObjectRequest actual =
+            UnifiedOpts.metagenerationMatch(1L)
+                .readObject()
+                .apply(ReadObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void getObject() {
+        GetObjectRequest expected =
+            GetObjectRequest.newBuilder().setIfMetagenerationMatch(1L).build();
+        GetObjectRequest actual =
+            UnifiedOpts.metagenerationMatch(1L)
+                .getObject()
+                .apply(GetObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void updateObject() {
+        UpdateObjectRequest expected =
+            UpdateObjectRequest.newBuilder().setIfMetagenerationMatch(1L).build();
+        UpdateObjectRequest actual =
+            UnifiedOpts.metagenerationMatch(1L)
+                .updateObject()
+                .apply(UpdateObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void deleteObject() {
+        DeleteObjectRequest expected =
+            DeleteObjectRequest.newBuilder().setIfMetagenerationMatch(1L).build();
+        DeleteObjectRequest actual =
+            UnifiedOpts.metagenerationMatch(1L)
+                .deleteObject()
+                .apply(DeleteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void composeObject() {
+        ComposeObjectRequest expected =
+            ComposeObjectRequest.newBuilder().setIfMetagenerationMatch(1L).build();
+        ComposeObjectRequest actual =
+            UnifiedOpts.metagenerationMatch(1L)
+                .composeObject()
+                .apply(ComposeObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void updateBucket() {
+        UpdateBucketRequest expected =
+            UpdateBucketRequest.newBuilder().setIfMetagenerationMatch(1L).build();
+        UpdateBucketRequest actual =
+            UnifiedOpts.metagenerationMatch(1L)
+                .updateBucket()
+                .apply(UpdateBucketRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void deleteBucket() {
+        DeleteBucketRequest expected =
+            DeleteBucketRequest.newBuilder().setIfMetagenerationMatch(1L).build();
+        DeleteBucketRequest actual =
+            UnifiedOpts.metagenerationMatch(1L)
+                .deleteBucket()
+                .apply(DeleteBucketRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void getBucket() {
+        GetBucketRequest expected =
+            GetBucketRequest.newBuilder().setIfMetagenerationMatch(1L).build();
+        GetBucketRequest actual =
+            UnifiedOpts.metagenerationMatch(1L)
+                .getBucket()
+                .apply(GetBucketRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class MetagenerationNotMatchTest {
+      @Test
+      public void writeObject() {
+        WriteObjectRequest expected =
+            WriteObjectRequest.newBuilder()
+                .setWriteObjectSpec(WriteObjectSpec.newBuilder().setIfMetagenerationNotMatch(1L))
+                .build();
+        WriteObjectRequest actual =
+            UnifiedOpts.metagenerationNotMatch(1L)
+                .writeObject()
+                .apply(WriteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void readObject() {
+        ReadObjectRequest expected =
+            ReadObjectRequest.newBuilder().setIfMetagenerationNotMatch(1L).build();
+        ReadObjectRequest actual =
+            UnifiedOpts.metagenerationNotMatch(1L)
+                .readObject()
+                .apply(ReadObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void getObject() {
+        GetObjectRequest expected =
+            GetObjectRequest.newBuilder().setIfMetagenerationNotMatch(1L).build();
+        GetObjectRequest actual =
+            UnifiedOpts.metagenerationNotMatch(1L)
+                .getObject()
+                .apply(GetObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void updateObject() {
+        UpdateObjectRequest expected =
+            UpdateObjectRequest.newBuilder().setIfMetagenerationNotMatch(1L).build();
+        UpdateObjectRequest actual =
+            UnifiedOpts.metagenerationNotMatch(1L)
+                .updateObject()
+                .apply(UpdateObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void deleteObject() {
+        DeleteObjectRequest expected =
+            DeleteObjectRequest.newBuilder().setIfMetagenerationNotMatch(1L).build();
+        DeleteObjectRequest actual =
+            UnifiedOpts.metagenerationNotMatch(1L)
+                .deleteObject()
+                .apply(DeleteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void updateBucket() {
+        UpdateBucketRequest expected =
+            UpdateBucketRequest.newBuilder().setIfMetagenerationNotMatch(1L).build();
+        UpdateBucketRequest actual =
+            UnifiedOpts.metagenerationNotMatch(1L)
+                .updateBucket()
+                .apply(UpdateBucketRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void deleteBucket() {
+        DeleteBucketRequest expected =
+            DeleteBucketRequest.newBuilder().setIfMetagenerationNotMatch(1L).build();
+        DeleteBucketRequest actual =
+            UnifiedOpts.metagenerationNotMatch(1L)
+                .deleteBucket()
+                .apply(DeleteBucketRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void getBucket() {
+        GetBucketRequest expected =
+            GetBucketRequest.newBuilder().setIfMetagenerationNotMatch(1L).build();
+        GetBucketRequest actual =
+            UnifiedOpts.metagenerationNotMatch(1L)
+                .getBucket()
+                .apply(GetBucketRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class PageSizeTest {
+      @Test
+      public void listBuckets() {
+        ListBucketsRequest expected = ListBucketsRequest.newBuilder().setPageSize(5).build();
+
+        ListBucketsRequest actual =
+            UnifiedOpts.pageSize(5).listBuckets().apply(ListBucketsRequest.newBuilder()).build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void listObjects() {
+        ListObjectsRequest expected = ListObjectsRequest.newBuilder().setPageSize(5).build();
+
+        ListObjectsRequest actual =
+            UnifiedOpts.pageSize(5).listObjects().apply(ListObjectsRequest.newBuilder()).build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void listHmacKeys() {
+        ListHmacKeysRequest expected = ListHmacKeysRequest.newBuilder().setPageSize(5).build();
+
+        ListHmacKeysRequest actual =
+            UnifiedOpts.pageSize(5).listHmacKeys().apply(ListHmacKeysRequest.newBuilder()).build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class PageTokenTest {
+      @Test
+      public void listBuckets() {
+        ListBucketsRequest expected =
+            ListBucketsRequest.newBuilder().setPageToken("pageToken").build();
+
+        ListBucketsRequest actual =
+            UnifiedOpts.pageToken("pageToken")
+                .listBuckets()
+                .apply(ListBucketsRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void listObjects() {
+        ListObjectsRequest expected =
+            ListObjectsRequest.newBuilder().setPageToken("pageToken").build();
+
+        ListObjectsRequest actual =
+            UnifiedOpts.pageToken("pageToken")
+                .listObjects()
+                .apply(ListObjectsRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void listHmacKeys() {
+        ListHmacKeysRequest expected =
+            ListHmacKeysRequest.newBuilder().setPageToken("pageToken").build();
+
+        ListHmacKeysRequest actual =
+            UnifiedOpts.pageToken("pageToken")
+                .listHmacKeys()
+                .apply(ListHmacKeysRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class PredefinedAclTest {
+      @Test
+      public void writeObject() {
+        WriteObjectRequest expected =
+            WriteObjectRequest.newBuilder()
+                .setWriteObjectSpec(WriteObjectSpec.newBuilder().setPredefinedAcl("private"))
+                .build();
+        WriteObjectRequest actual =
+            UnifiedOpts.predefinedAcl(PredefinedAcl.PRIVATE)
+                .writeObject()
+                .apply(WriteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void updateObject() {
+        UpdateObjectRequest expected =
+            UpdateObjectRequest.newBuilder().setPredefinedAcl("private").build();
+        UpdateObjectRequest actual =
+            UnifiedOpts.predefinedAcl(PredefinedAcl.PRIVATE)
+                .updateObject()
+                .apply(UpdateObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void composeObject() {
+        ComposeObjectRequest expected =
+            ComposeObjectRequest.newBuilder().setDestinationPredefinedAcl("private").build();
+        ComposeObjectRequest actual =
+            UnifiedOpts.predefinedAcl(PredefinedAcl.PRIVATE)
+                .composeObject()
+                .apply(ComposeObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void updateBucket() {
+        UpdateBucketRequest expected =
+            UpdateBucketRequest.newBuilder().setPredefinedAcl("private").build();
+        UpdateBucketRequest actual =
+            UnifiedOpts.predefinedAcl(PredefinedAcl.PRIVATE)
+                .updateBucket()
+                .apply(UpdateBucketRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void createBucket() {
+        CreateBucketRequest expected =
+            CreateBucketRequest.newBuilder().setPredefinedAcl("private").build();
+        CreateBucketRequest actual =
+            UnifiedOpts.predefinedAcl(PredefinedAcl.PRIVATE)
+                .createBucket()
+                .apply(CreateBucketRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class PredefinedDefaultObjectAclTest {
+      @Test
+      public void createBucket() {
+        CreateBucketRequest expected =
+            CreateBucketRequest.newBuilder().setPredefinedDefaultObjectAcl("private").build();
+        CreateBucketRequest actual =
+            UnifiedOpts.predefinedDefaultObjectAcl(PredefinedAcl.PRIVATE)
+                .createBucket()
+                .apply(CreateBucketRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void updateBucket() {
+        UpdateBucketRequest expected =
+            UpdateBucketRequest.newBuilder().setPredefinedDefaultObjectAcl("private").build();
+        UpdateBucketRequest actual =
+            UnifiedOpts.predefinedDefaultObjectAcl(PredefinedAcl.PRIVATE)
+                .updateBucket()
+                .apply(UpdateBucketRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class PrefixTest {
+      @Test
+      public void listBuckets() {
+        ListBucketsRequest expected = ListBucketsRequest.newBuilder().setPrefix("prefix").build();
+
+        ListBucketsRequest actual =
+            UnifiedOpts.prefix("prefix")
+                .listBuckets()
+                .apply(ListBucketsRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void listObjects() {
+        ListObjectsRequest expected = ListObjectsRequest.newBuilder().setPrefix("prefix").build();
+
+        ListObjectsRequest actual =
+            UnifiedOpts.prefix("prefix")
+                .listObjects()
+                .apply(ListObjectsRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class ProjectIdTest {
+      @Test
+      public void listHmacKeys_nonResource() {
+        ListHmacKeysRequest expected =
+            ListHmacKeysRequest.newBuilder().setProject("projects/proj").build();
+
+        ListHmacKeysRequest actual =
+            UnifiedOpts.projectId("proj")
+                .listHmacKeys()
+                .apply(ListHmacKeysRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void getHmacKey_nonResource() {
+        GetHmacKeyRequest expected =
+            GetHmacKeyRequest.newBuilder().setProject("projects/proj").build();
+
+        GetHmacKeyRequest actual =
+            UnifiedOpts.projectId("proj")
+                .getHmacKey()
+                .apply(GetHmacKeyRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void deleteHmacKey_nonResource() {
+        DeleteHmacKeyRequest expected =
+            DeleteHmacKeyRequest.newBuilder().setProject("projects/proj").build();
+
+        DeleteHmacKeyRequest actual =
+            UnifiedOpts.projectId("proj")
+                .deleteHmacKey()
+                .apply(DeleteHmacKeyRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void listHmacKeys_resource() {
+        ListHmacKeysRequest expected =
+            ListHmacKeysRequest.newBuilder().setProject("projects/proj").build();
+
+        ListHmacKeysRequest actual =
+            UnifiedOpts.projectId("projects/proj")
+                .listHmacKeys()
+                .apply(ListHmacKeysRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void getHmacKey_resource() {
+        GetHmacKeyRequest expected =
+            GetHmacKeyRequest.newBuilder().setProject("projects/proj").build();
+
+        GetHmacKeyRequest actual =
+            UnifiedOpts.projectId("projects/proj")
+                .getHmacKey()
+                .apply(GetHmacKeyRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void deleteHmacKey_resource() {
+        DeleteHmacKeyRequest expected =
+            DeleteHmacKeyRequest.newBuilder().setProject("projects/proj").build();
+
+        DeleteHmacKeyRequest actual =
+            UnifiedOpts.projectId("projects/proj")
+                .deleteHmacKey()
+                .apply(DeleteHmacKeyRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class SourceGenerationMatchTest {
+      @Test
+      public void rewriteObject() {
+        RewriteObjectRequest expected =
+            RewriteObjectRequest.newBuilder().setIfSourceGenerationMatch(1L).build();
+        RewriteObjectRequest actual =
+            UnifiedOpts.generationMatch(1L)
+                .asSource()
+                .rewriteObject()
+                .apply(RewriteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class SourceGenerationNotMatchTest {
+      @Test
+      public void rewriteObject() {
+        RewriteObjectRequest expected =
+            RewriteObjectRequest.newBuilder().setIfSourceGenerationNotMatch(1L).build();
+        RewriteObjectRequest actual =
+            UnifiedOpts.generationNotMatch(1L)
+                .asSource()
+                .rewriteObject()
+                .apply(RewriteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class SourceMetagenerationMatchTest {
+      @Test
+      public void rewriteObject() {
+        RewriteObjectRequest expected =
+            RewriteObjectRequest.newBuilder().setIfSourceMetagenerationMatch(1L).build();
+        RewriteObjectRequest actual =
+            UnifiedOpts.metagenerationMatch(1L)
+                .asSource()
+                .rewriteObject()
+                .apply(RewriteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class SourceMetagenerationNotMatchTest {
+      @Test
+      public void rewriteObject() {
+        RewriteObjectRequest expected =
+            RewriteObjectRequest.newBuilder().setIfSourceMetagenerationNotMatch(1L).build();
+        RewriteObjectRequest actual =
+            UnifiedOpts.metagenerationNotMatch(1L)
+                .asSource()
+                .rewriteObject()
+                .apply(RewriteObjectRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class RequestedPolicyVersionTest {
+      @Test
+      public void getIamPolicy() {
+        GetIamPolicyRequest expected =
+            GetIamPolicyRequest.newBuilder()
+                .setOptions(GetPolicyOptions.newBuilder().setRequestedPolicyVersion(3).build())
+                .build();
+        GetIamPolicyRequest actual =
+            UnifiedOpts.requestedPolicyVersion(3)
+                .getIamPolicy()
+                .apply(GetIamPolicyRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class ServiceAccountTest {
+      @Test
+      public void listHmacKeys() {
+        ListHmacKeysRequest expected =
+            ListHmacKeysRequest.newBuilder().setServiceAccountEmail("x@y.z").build();
+
+        ListHmacKeysRequest actual =
+            UnifiedOpts.serviceAccount(ServiceAccount.of("x@y.z"))
+                .listHmacKeys()
+                .apply(ListHmacKeysRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class SetContentTypeTest {
+      @Test
+      public void writeObject() {
+        WriteObjectRequest expected =
+            WriteObjectRequest.newBuilder()
+                .setWriteObjectSpec(
+                    WriteObjectSpec.newBuilder()
+                        .setResource(Object.newBuilder().setContentType("text/plain").build())
+                        .build())
+                .build();
+
+        WriteObjectRequest actual =
+            UnifiedOpts.setContentType("text/plain")
+                .writeObject()
+                .apply(WriteObjectRequest.newBuilder())
+                .build();
+
+        assertThat(actual).isEqualTo(expected);
+      }
+
+      @Test
+      public void updateObject() {
+        UpdateObjectRequest expected =
+            UpdateObjectRequest.newBuilder()
+                .setObject(Object.newBuilder().setContentType("text/plain").build())
+                .build();
+
+        UpdateObjectRequest actual =
+            UnifiedOpts.setContentType("text/plain")
+                .updateObject()
+                .apply(UpdateObjectRequest.newBuilder())
+                .build();
+
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class ShowDeletedKeysTest {
+      @Test
+      public void listHmacKeys() {
+        ListHmacKeysRequest expected =
+            ListHmacKeysRequest.newBuilder().setShowDeletedKeys(true).build();
+
+        ListHmacKeysRequest actual =
+            UnifiedOpts.showDeletedKeys(true)
+                .listHmacKeys()
+                .apply(ListHmacKeysRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class StartOffsetTest {
+      @Test
+      public void listObjects() {
+        ListObjectsRequest expected =
+            ListObjectsRequest.newBuilder().setLexicographicStart("start").build();
+
+        ListObjectsRequest actual =
+            UnifiedOpts.startOffset("start")
+                .listObjects()
+                .apply(ListObjectsRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class UserProjectTest {
+      @Test
+      public void grpcCallContext() {
+        GrpcCallContext expected =
+            GrpcCallContext.createDefault()
+                .withExtraHeaders(
+                    ImmutableMap.of("X-Goog-User-Project", ImmutableList.of("user-project")));
+        GrpcCallContext actual =
+            UnifiedOpts.userProject("user-project")
+                .getGrpcMetadataMapper()
+                .apply(GrpcCallContext.createDefault());
+
+        assertThat(actual.getExtraHeaders()).isEqualTo(expected.getExtraHeaders());
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+
+    public static final class VersionsFilterTest {
+      @Test
+      public void listObjects() {
+        ListObjectsRequest expected = ListObjectsRequest.newBuilder().setVersions(true).build();
+
+        ListObjectsRequest actual =
+            UnifiedOpts.versionsFilter(true)
+                .listObjects()
+                .apply(ListObjectsRequest.newBuilder())
+                .build();
+        assertThat(actual).isEqualTo(expected);
+      }
+    }
+  }
+
+  @RunWith(Enclosed.class)
+  public static final class Extractor {
+
+    public static final class Crc32cMatchExtractorTest {
+
+      @Test
+      public void extractFromBlobInfo_nonNull() {
+        BlobInfo info = BlobInfo.newBuilder("b", "o").setCrc32c("crc32c").build();
+        ObjectTargetOpt opt = UnifiedOpts.crc32cMatchExtractor().extractFromBlobInfo(info);
+        assertThat(opt).isEqualTo(UnifiedOpts.crc32cMatch("crc32c"));
+      }
+
+      @Test
+      public void extractFromBlobInfo_null() {
+        BlobInfo info = BlobInfo.newBuilder("b", "o").build();
+        ObjectTargetOpt opt = UnifiedOpts.crc32cMatchExtractor().extractFromBlobInfo(info);
+        assertThat(opt).isEqualTo(NoOpObjectTargetOpt.INSTANCE);
+      }
+
+      @Test
+      public void extractFromBlobId_noop() {
+        ObjectTargetOpt opt = UnifiedOpts.crc32cMatchExtractor().extractFromBlobId(null);
+        assertThat(opt).isEqualTo(NoOpObjectTargetOpt.INSTANCE);
+      }
+    }
+
+    public static final class DetectContentTypeTest {
+
+      @Test
+      public void hasExtension() {
+        BlobInfo info1 = BlobInfo.newBuilder("bucket", "obj.txt").build();
+        ObjectTargetOpt opt = UnifiedOpts.detectContentType().extractFromBlobInfo(info1);
+
+        assertThat(opt).isEqualTo(UnifiedOpts.setContentType("text/plain"));
+      }
+
+      @Test
+      public void noopIfAlreadySpecified() {
+        BlobInfo info1 =
+            BlobInfo.newBuilder("bucket", "obj.txt").setContentType("text/plain").build();
+        ObjectTargetOpt opt = UnifiedOpts.detectContentType().extractFromBlobInfo(info1);
+
+        assertThat(opt).isEqualTo(NoOpObjectTargetOpt.INSTANCE);
+      }
+
+      @Test
+      public void baseCaseIsApplicationOctetStream() {
+        BlobInfo info1 = BlobInfo.newBuilder("bucket", "obj").build();
+        ObjectTargetOpt opt = UnifiedOpts.detectContentType().extractFromBlobInfo(info1);
+
+        assertThat(opt).isEqualTo(UnifiedOpts.setContentType("application/octet-stream"));
+      }
+    }
+
+    public static final class GenerationMatchExtractorTest {
+      @Test
+      public void extractFromBlobInfo_nonNull() {
+        BlobInfo info = BlobInfo.newBuilder("b", "o", 3L).build();
+        ObjectTargetOpt opt = UnifiedOpts.generationMatchExtractor().extractFromBlobInfo(info);
+        assertThat(opt).isEqualTo(UnifiedOpts.generationMatch(3L));
+      }
+
+      @Test
+      public void extractFromBlobInfo_null() {
+        BlobInfo info = BlobInfo.newBuilder("b", "o").build();
+        assertThat(
+                assertThrows(
+                    IllegalArgumentException.class,
+                    () -> UnifiedOpts.generationMatchExtractor().extractFromBlobInfo(info)))
+            .hasMessageThat()
+            .contains("ifGenerationMatch");
+      }
+
+      @Test
+      public void extractFromBlobId_nonNull() {
+        BlobId id = BlobId.of("b", "o", 3L);
+        ObjectTargetOpt opt = UnifiedOpts.generationMatchExtractor().extractFromBlobId(id);
+        assertThat(opt).isEqualTo(UnifiedOpts.generationMatch(3L));
+      }
+
+      @Test
+      public void extractFromBlobId_null() {
+        BlobId id = BlobId.of("b", "o");
+        assertThat(
+                assertThrows(
+                    IllegalArgumentException.class,
+                    () -> UnifiedOpts.generationMatchExtractor().extractFromBlobId(id)))
+            .hasMessageThat()
+            .contains("ifGenerationMatch");
+      }
+    }
+
+    public static final class GenerationNotMatchExtractorTest {
+      @Test
+      public void extractFromBlobInfo_nonNull() {
+        BlobInfo info = BlobInfo.newBuilder("b", "o", 3L).build();
+        ObjectTargetOpt opt = UnifiedOpts.generationNotMatchExtractor().extractFromBlobInfo(info);
+        assertThat(opt).isEqualTo(UnifiedOpts.generationNotMatch(3L));
+      }
+
+      @Test
+      public void extractFromBlobInfo_null() {
+        BlobInfo info = BlobInfo.newBuilder("b", "o").build();
+        assertThat(
+                assertThrows(
+                    IllegalArgumentException.class,
+                    () -> UnifiedOpts.generationNotMatchExtractor().extractFromBlobInfo(info)))
+            .hasMessageThat()
+            .contains("ifGenerationNotMatch");
+      }
+
+      @Test
+      public void extractFromBlobId_nonNull() {
+        BlobId id = BlobId.of("b", "o", 3L);
+        ObjectTargetOpt opt = UnifiedOpts.generationNotMatchExtractor().extractFromBlobId(id);
+        assertThat(opt).isEqualTo(UnifiedOpts.generationNotMatch(3L));
+      }
+
+      @Test
+      public void extractFromBlobId_null() {
+        BlobId id = BlobId.of("b", "o");
+        assertThat(
+                assertThrows(
+                    IllegalArgumentException.class,
+                    () -> UnifiedOpts.generationNotMatchExtractor().extractFromBlobId(id)))
+            .hasMessageThat()
+            .contains("ifGenerationNotMatch");
+      }
+    }
+
+    public static final class Md5MatchExtractorTest {
+
+      @Test
+      public void extractFromBlobInfo_nonNull() {
+        BlobInfo info = BlobInfo.newBuilder("b", "o").setMd5("md5").build();
+        ObjectTargetOpt opt = UnifiedOpts.md5MatchExtractor().extractFromBlobInfo(info);
+        assertThat(opt).isEqualTo(UnifiedOpts.md5Match("md5"));
+      }
+
+      @Test
+      public void extractFromBlobInfo_null() {
+        BlobInfo info = BlobInfo.newBuilder("b", "o").build();
+        ObjectTargetOpt opt = UnifiedOpts.md5MatchExtractor().extractFromBlobInfo(info);
+        assertThat(opt).isEqualTo(NoOpObjectTargetOpt.INSTANCE);
+      }
+
+      @Test
+      public void extractFromBlobId_noop() {
+        ObjectTargetOpt opt = UnifiedOpts.md5MatchExtractor().extractFromBlobId(null);
+        assertThat(opt).isEqualTo(NoOpObjectTargetOpt.INSTANCE);
+      }
+    }
+
+    public static final class MetagenerationMatchExtractorTest {
+      @Test
+      public void extractFromBlobInfo_nonNull() {
+        BlobInfo info = BlobInfo.newBuilder("b", "o").setMetageneration(1L).build();
+        ObjectTargetOpt opt = UnifiedOpts.metagenerationMatchExtractor().extractFromBlobInfo(info);
+        assertThat(opt).isEqualTo(UnifiedOpts.metagenerationMatch(1L));
+      }
+
+      @Test
+      public void extractFromBlobInfo_null() {
+        BlobInfo info = BlobInfo.newBuilder("b", "o").build();
+        assertThat(
+                assertThrows(
+                    IllegalArgumentException.class,
+                    () -> UnifiedOpts.metagenerationMatchExtractor().extractFromBlobInfo(info)))
+            .hasMessageThat()
+            .contains("ifMetagenerationMatch");
+      }
+
+      @Test
+      public void extractFromBlobId_noop() {
+        ObjectTargetOpt opt = UnifiedOpts.metagenerationMatchExtractor().extractFromBlobId(null);
+        assertThat(opt).isEqualTo(NoOpObjectTargetOpt.INSTANCE);
+      }
+
+      @Test
+      public void extractFromBucketInfo_nonNull() {
+        BucketInfo info = BucketInfo.newBuilder("b").setMetageneration(1L).build();
+        ObjectTargetOpt opt =
+            UnifiedOpts.metagenerationMatchExtractor().extractFromBucketInfo(info);
+        assertThat(opt).isEqualTo(UnifiedOpts.metagenerationMatch(1L));
+      }
+
+      @Test
+      public void extractFromBucketInfo_null() {
+        BucketInfo info = BucketInfo.newBuilder("b").build();
+        assertThat(
+                assertThrows(
+                    IllegalArgumentException.class,
+                    () -> UnifiedOpts.metagenerationMatchExtractor().extractFromBucketInfo(info)))
+            .hasMessageThat()
+            .contains("ifMetagenerationMatch");
+      }
+    }
+
+    public static final class MetagenerationNotMatchExtractorTest {
+      @Test
+      public void extractFromBlobInfo_nonNull() {
+        BlobInfo info = BlobInfo.newBuilder("b", "o").setMetageneration(1L).build();
+        ObjectTargetOpt opt =
+            UnifiedOpts.metagenerationNotMatchExtractor().extractFromBlobInfo(info);
+        assertThat(opt).isEqualTo(UnifiedOpts.metagenerationNotMatch(1L));
+      }
+
+      @Test
+      public void extractFromBlobInfo_null() {
+        BlobInfo info = BlobInfo.newBuilder("b", "o").build();
+        assertThat(
+                assertThrows(
+                    IllegalArgumentException.class,
+                    () -> UnifiedOpts.metagenerationNotMatchExtractor().extractFromBlobInfo(info)))
+            .hasMessageThat()
+            .contains("ifMetagenerationNotMatch");
+      }
+
+      @Test
+      public void extractFromBlobId_noop() {
+        ObjectTargetOpt opt = UnifiedOpts.metagenerationNotMatchExtractor().extractFromBlobId(null);
+        assertThat(opt).isEqualTo(NoOpObjectTargetOpt.INSTANCE);
+      }
+
+      @Test
+      public void extractFromBucketInfo_nonNull() {
+        BucketInfo info = BucketInfo.newBuilder("b").setMetageneration(1L).build();
+        ObjectTargetOpt opt =
+            UnifiedOpts.metagenerationNotMatchExtractor().extractFromBucketInfo(info);
+        assertThat(opt).isEqualTo(UnifiedOpts.metagenerationNotMatch(1L));
+      }
+
+      @Test
+      public void extractFromBucketInfo_null() {
+        BucketInfo info = BucketInfo.newBuilder("b").build();
+        assertThat(
+                assertThrows(
+                    IllegalArgumentException.class,
+                    () ->
+                        UnifiedOpts.metagenerationNotMatchExtractor().extractFromBucketInfo(info)))
+            .hasMessageThat()
+            .contains("ifMetagenerationNotMatch");
+      }
+    }
+  }
+
+  public static final class NoOpObjectTargetOptTest {
+    @Test
+    public void grpcMetadataMapper() {
+      GrpcCallContext ctx1 = GrpcCallContext.createDefault();
+      GrpcCallContext ctx2 = NoOpObjectTargetOpt.INSTANCE.getGrpcMetadataMapper().apply(ctx1);
+      assertThat(ctx2).isEqualTo(ctx1);
+      assertThat(ctx2).isSameInstanceAs(ctx1);
+    }
+
+    @Test
+    public void blobInfo() {
+      BlobInfo.Builder b1 = BlobInfo.newBuilder("b", "o");
+      // "checkpoint" our builder to allow for comparison
+      BlobInfo expected = b1.build();
+      BlobInfo.Builder b2 = NoOpObjectTargetOpt.INSTANCE.blobInfo().apply(b1);
+      assertThat(b2.build()).isEqualTo(expected);
+      assertThat(b2).isSameInstanceAs(b1);
+    }
+  }
+
+  public static final class SourceAndTargetOpt {
+    @Test
+    public void rewriteObject_decryptAndEncrypt() {
+      CommonObjectRequestParams source = randEncryption();
+      CommonObjectRequestParams dest = randEncryption();
+      SecretKeySpec keySource =
+          new SecretKeySpec(
+              source.getEncryptionKeyBytes().toByteArray(), source.getEncryptionAlgorithm());
+      SecretKeySpec keyDest =
+          new SecretKeySpec(
+              dest.getEncryptionKeyBytes().toByteArray(), dest.getEncryptionAlgorithm());
+
+      RewriteObjectRequest expected =
+          RewriteObjectRequest.newBuilder()
+              .setCopySourceEncryptionAlgorithm(source.getEncryptionAlgorithm())
+              .setCopySourceEncryptionKeyBytes(source.getEncryptionKeyBytes())
+              .setCopySourceEncryptionKeySha256Bytes(source.getEncryptionKeySha256Bytes())
+              .setCommonObjectRequestParams(dest)
+              .build();
+      Opts<UnifiedOpts.Opt> opts =
+          Opts.from(UnifiedOpts.decryptionKey(keySource), UnifiedOpts.encryptionKey(keyDest));
+      Mapper<RewriteObjectRequest.Builder> mapper = opts.rewriteObjectsRequest();
+
+      RewriteObjectRequest actual = mapper.apply(RewriteObjectRequest.newBuilder()).build();
+      assertThat(actual).isEqualTo(expected);
+    }
+  }
+
+  private static CommonObjectRequestParams randEncryption() {
+    byte[] bytes = new byte[32];
+    new SecureRandom().nextBytes(bytes);
+    return CommonObjectRequestParams.newBuilder()
+        .setEncryptionAlgorithm("AES256")
+        .setEncryptionKeyBytes(ByteString.copyFrom(bytes))
+        .setEncryptionKeySha256Bytes(
+            ByteString.copyFrom(Hashing.sha256().hashBytes(bytes).asBytes()))
+        .build();
+  }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ZOptTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ZOptTest.java
deleted file mode 100644
index 7508ad38..00000000
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/ZOptTest.java
+++ /dev/null
@@ -1,137 +0,0 @@
-/*
- * Copyright 2022 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 static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
-
-import com.google.cloud.storage.spi.v1.StorageRpc;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import java.lang.reflect.Field;
-import java.lang.reflect.Modifier;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.concurrent.atomic.AtomicReference;
-import org.junit.Test;
-
-public final class ZOptTest {
-
-  @Test
-  public void happyPath() {
-    AtomicReference<String> ref1 = new AtomicReference<>();
-    AtomicLong ref2 = new AtomicLong(Long.MAX_VALUE);
-
-    ImmutableMap<StorageRpc.Option, ?> map =
-        ImmutableMap.of(StorageRpc.Option.PREDEFINED_ACL, "acl");
-    ZOpt.PREDEFINED_ACL.consumeVia(ref1::set).apply(map);
-    ZOpt.IF_GENERATION_MATCH.consumeVia(ref2::set).apply(map);
-
-    assertThat(ref1.get()).isEqualTo("acl");
-    assertThat(ref2.get()).isEqualTo(Long.MAX_VALUE);
-  }
-
-  @Test
-  public void applyAll() {
-    AtomicReference<String> ref1 = new AtomicReference<>();
-    AtomicLong ref2 = new AtomicLong(Long.MAX_VALUE);
-
-    ImmutableMap<StorageRpc.Option, ?> map =
-        ImmutableMap.of(StorageRpc.Option.PREDEFINED_ACL, "acl");
-    ZOpt.applyAll(
-        map,
-        ZOpt.PREDEFINED_ACL.consumeVia(ref1::set),
-        ZOpt.IF_GENERATION_MATCH.consumeVia(ref2::set));
-
-    assertThat(ref1.get()).isEqualTo("acl");
-    assertThat(ref2.get()).isEqualTo(Long.MAX_VALUE);
-  }
-
-  @Test
-  public void aNullValueShouldNotBeProvidedToTheConsumer() {
-    ZOpt.PREDEFINED_ACL
-        .consumeVia((v) -> fail("null should not be provided"))
-        .apply(Collections.emptyMap());
-  }
-
-  @Test
-  public void ifMapperProducesNullItShouldNotBeProvidedToTheConsumer() {
-    ImmutableMap<StorageRpc.Option, ?> map =
-        ImmutableMap.of(StorageRpc.Option.PREDEFINED_ACL, "acl");
-    ZOpt.PREDEFINED_ACL
-        .mapThenConsumeVia((t) -> null, (v) -> fail("null should not be provided"))
-        .apply(map);
-  }
-
-  @Test
-  public void ensureEachTypeWorks() {
-    AtomicReference<String> ref1 = new AtomicReference<>();
-    AtomicLong ref2 = new AtomicLong(Long.MAX_VALUE);
-    AtomicBoolean ref3 = new AtomicBoolean(false);
-
-    ImmutableMap<StorageRpc.Option, ?> map =
-        ImmutableMap.of(
-            StorageRpc.Option.PREDEFINED_ACL,
-            "acl",
-            StorageRpc.Option.IF_GENERATION_MATCH,
-            0L,
-            StorageRpc.Option.IF_DISABLE_GZIP_CONTENT,
-            true);
-    ZOpt.applyAll(
-        map,
-        ZOpt.PREDEFINED_ACL.consumeVia(ref1::set),
-        ZOpt.IF_GENERATION_MATCH.consumeVia(ref2::set),
-        ZOpt.IF_DISABLE_GZIP_CONTENT.consumeVia(ref3::set));
-
-    assertThat(ref1.get()).isEqualTo("acl");
-    assertThat(ref2.get()).isEqualTo(0L);
-    assertThat(ref3.get()).isTrue();
-  }
-
-  @Test
-  public void validateCoverageOfAllStorageRpcOptions() {
-    Comparator<StorageRpc.Option> comp = Comparator.comparingInt(Enum::ordinal);
-
-    ImmutableList<StorageRpc.Option> expected =
-        Arrays.stream(StorageRpc.Option.values())
-            .sorted(comp)
-            .collect(ImmutableList.toImmutableList());
-
-    ImmutableList<StorageRpc.Option> actual =
-        Arrays.stream(ZOpt.class.getDeclaredFields())
-            .filter(f -> Modifier.isStatic(f.getModifiers()))
-            .filter(f -> !Modifier.isPrivate(f.getModifiers()))
-            .filter(f -> f.getType().equals(ZOpt.class))
-            .map(ZOptTest::unsafeGet)
-            .map(ZOpt::getKey)
-            .sorted(comp)
-            .collect(ImmutableList.toImmutableList());
-
-    assertThat(actual).containsExactlyElementsIn(expected);
-  }
-
-  private static ZOpt<?> unsafeGet(Field f) {
-    try {
-      return (ZOpt<?>) f.get(null);
-    } catch (IllegalAccessException e) {
-      throw new RuntimeException("error attempting to access field: " + f, e);
-    }
-  }
-}