Repository: googleapis/java-storage
Pull request #2882 (2025-01-14 21:08:45)

feat: add new Storage#moveBlob method to atomically rename an object


Patch:
diff --git a/.github/release-please.yml b/.github/release-please.yml
index 1b56aa43..73414624 100644
--- a/.github/release-please.yml
+++ b/.github/release-please.yml
@@ -52,3 +52,10 @@ branches:
       - README.md
       - .readme-partials.yaml
     branch: 2.38.x
+  - bumpMinorPreMajor: true
+    handleGHRelease: true
+    releaseType: java-backport
+    extraFiles:
+      - README.md
+      - .readme-partials.yaml
+    branch: 2.47.x
diff --git a/.github/sync-repo-settings.yaml b/.github/sync-repo-settings.yaml
index 3336e9ee..b4176340 100644
--- a/.github/sync-repo-settings.yaml
+++ b/.github/sync-repo-settings.yaml
@@ -163,6 +163,23 @@ branchProtectionRules:
       - 'Kokoro - Test: Java GraalVM Native Image'
       - 'Kokoro - Test: Java 17 GraalVM Native Image'
       - javadoc
+  - pattern: 2.47.x
+    isAdminEnforced: true
+    requiredApprovingReviewCount: 1
+    requiresCodeOwnerReviews: true
+    requiresStrictStatusChecks: false
+    requiredStatusCheckContexts:
+      - dependencies (17)
+      - lint
+      - clirr
+      - units (8)
+      - units (11)
+      - 'Kokoro - Test: Integration'
+      - cla/google
+      - 'Kokoro - Test: Java GraalVM Native Image'
+      - 'Kokoro - Test: Java 17 GraalVM Native Image'
+      - javadoc
+      - unmanaged_dependency_check
 permissionRules:
   - team: yoshi-admins
     permission: admin
diff --git a/README.md b/README.md
index a5dbb98d..017d2d74 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ If you are using Maven with [BOM][libraries-bom], add this to your pom.xml file:
     <dependency>
       <groupId>com.google.cloud</groupId>
       <artifactId>libraries-bom</artifactId>
-      <version>26.52.0</version>
+      <version>26.53.0</version>
       <type>pom</type>
       <scope>import</scope>
     </dependency>
@@ -59,7 +59,7 @@ If you are using Maven without the BOM, add this to your dependencies:
 If you are using Gradle 5.x or later, add this to your dependencies:
 
 ```Groovy
-implementation platform('com.google.cloud:libraries-bom:26.52.0')
+implementation platform('com.google.cloud:libraries-bom:26.53.0')
 
 implementation 'com.google.cloud:google-cloud-storage'
 ```
diff --git a/generation_config.yaml b/generation_config.yaml
index 50bfa407..efa72100 100644
--- a/generation_config.yaml
+++ b/generation_config.yaml
@@ -1,6 +1,6 @@
 gapic_generator_version: 2.51.1
-googleapis_commitish: 00196e2a68b16a864c57db2e870822875a7f1198
-libraries_bom_version: 26.52.0
+googleapis_commitish: 318818b22ec2bd44ebe43fe662418b7dff032abf
+libraries_bom_version: 26.53.0
 libraries:
   - api_shortname: storage
     name_pretty: Cloud Storage
diff --git a/google-cloud-storage/clirr-ignored-differences.xml b/google-cloud-storage/clirr-ignored-differences.xml
index 0b19230f..060db7c3 100644
--- a/google-cloud-storage/clirr-ignored-differences.xml
+++ b/google-cloud-storage/clirr-ignored-differences.xml
@@ -108,5 +108,16 @@
     <method>io.opentelemetry.api.OpenTelemetry getOpenTelemetry()</method>
   </difference>
 
+  <!-- Move Object -->
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/storage/Storage</className>
+    <method>* moveBlob(*)</method>
+  </difference>
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/storage/spi/v1/StorageRpc</className>
+    <method>* moveObject(*)</method>
+  </difference>
 
 </differences>
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcRetryAlgorithmManager.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcRetryAlgorithmManager.java
index d8a126c9..ef125fa4 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcRetryAlgorithmManager.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcRetryAlgorithmManager.java
@@ -32,6 +32,7 @@ import com.google.storage.v2.GetObjectRequest;
 import com.google.storage.v2.ListBucketsRequest;
 import com.google.storage.v2.ListObjectsRequest;
 import com.google.storage.v2.LockBucketRetentionPolicyRequest;
+import com.google.storage.v2.MoveObjectRequest;
 import com.google.storage.v2.QueryWriteStatusRequest;
 import com.google.storage.v2.ReadObjectRequest;
 import com.google.storage.v2.RestoreObjectRequest;
@@ -124,6 +125,12 @@ final class GrpcRetryAlgorithmManager implements Serializable {
         : retryStrategy.getNonidempotentHandler();
   }
 
+  public ResultRetryAlgorithm<?> getFor(MoveObjectRequest req) {
+    return req.hasIfGenerationMatch()
+        ? retryStrategy.getIdempotentHandler()
+        : retryStrategy.getNonidempotentHandler();
+  }
+
   public ResultRetryAlgorithm<?> getFor(SetIamPolicyRequest req) {
     if (req.getPolicy().getEtag().equals(ByteString.empty())) {
       return retryStrategy.getNonidempotentHandler();
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 428f85bc..69781270 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
@@ -90,6 +90,7 @@ import com.google.storage.v2.ListBucketsRequest;
 import com.google.storage.v2.ListObjectsRequest;
 import com.google.storage.v2.ListObjectsResponse;
 import com.google.storage.v2.LockBucketRetentionPolicyRequest;
+import com.google.storage.v2.MoveObjectRequest;
 import com.google.storage.v2.Object;
 import com.google.storage.v2.ObjectAccessControl;
 import com.google.storage.v2.ReadObjectRequest;
@@ -1418,6 +1419,31 @@ final class GrpcStorageImpl extends BaseService<StorageOptions>
     return BlobWriteSessions.of(writableByteChannelSession);
   }
 
+  @Override
+  public Blob moveBlob(MoveBlobRequest request) {
+    Object srcObj = codecs.blobId().encode(request.getSource());
+    Object dstObj = codecs.blobId().encode(request.getTarget());
+    Opts<ObjectSourceOpt> srcOpts =
+        Opts.unwrap(request.getSourceOptions()).resolveFrom(request.getSource()).projectAsSource();
+    Opts<ObjectTargetOpt> dstOpts =
+        Opts.unwrap(request.getTargetOptions()).resolveFrom(request.getTarget());
+    MoveObjectRequest.Builder b =
+        MoveObjectRequest.newBuilder()
+            .setBucket(srcObj.getBucket())
+            .setSourceObject(srcObj.getName())
+            .setDestinationObject(dstObj.getName());
+
+    srcOpts.moveObjectsRequest().apply(b);
+    dstOpts.moveObjectsRequest().apply(b);
+
+    MoveObjectRequest req = b.build();
+    return Retrying.run(
+        getOptions(),
+        retryAlgorithmManager.getFor(req),
+        () -> storageClient.moveObjectCallable().call(req),
+        syntaxDecoders.blob);
+  }
+
   @Override
   public GrpcStorageOptions getOptions() {
     return (GrpcStorageOptions) super.getOptions();
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpRetryAlgorithmManager.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpRetryAlgorithmManager.java
index b2315cd8..a2564860 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpRetryAlgorithmManager.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpRetryAlgorithmManager.java
@@ -27,6 +27,7 @@ import com.google.api.services.storage.model.StorageObject;
 import com.google.cloud.storage.spi.v1.StorageRpc;
 import com.google.cloud.storage.spi.v1.StorageRpc.RewriteRequest;
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableMap;
 import java.io.Serializable;
 import java.util.List;
 import java.util.Map;
@@ -236,6 +237,14 @@ final class HttpRetryAlgorithmManager implements Serializable {
         : retryStrategy.getNonidempotentHandler();
   }
 
+  public ResultRetryAlgorithm<?> getForObjectsMove(
+      ImmutableMap<StorageRpc.Option, ?> sourceOptions,
+      ImmutableMap<StorageRpc.Option, ?> targetOptions) {
+    return targetOptions.containsKey(StorageRpc.Option.IF_GENERATION_MATCH)
+        ? retryStrategy.getIdempotentHandler()
+        : retryStrategy.getNonidempotentHandler();
+  }
+
   public ResultRetryAlgorithm<?> getForObjectsCompose(
       List<StorageObject> sources, StorageObject target, Map<StorageRpc.Option, ?> optionsMap) {
     return optionsMap.containsKey(StorageRpc.Option.IF_GENERATION_MATCH)
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java
index 2b13fbfb..dc92c342 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java
@@ -1434,6 +1434,25 @@ final class OtelStorageDecorator implements Storage {
     }
   }
 
+  @Override
+  public Blob moveBlob(MoveBlobRequest request) {
+    Span span =
+        tracer
+            .spanBuilder("moveBlob")
+            .setAttribute("gsutil.uri.source", request.getSource().toGsUtilUriWithGeneration())
+            .setAttribute("gsutil.uri.target", request.getTarget().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.moveBlob(request);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
   @Override
   public StorageOptions getOptions() {
     return delegate.getOptions();
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
index 8b719396..2fa09602 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
@@ -50,6 +50,7 @@ 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.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -73,6 +74,7 @@ import java.util.LinkedHashSet;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Stream;
@@ -2778,6 +2780,150 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
     }
   }
 
+  /**
+   * A class to contain all information needed for a Google Cloud Storage Object Move.
+   *
+   * @since 2.48.0
+   * @see Storage#moveBlob(MoveBlobRequest)
+   */
+  @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+  final class MoveBlobRequest {
+    private final BlobId source;
+    private final BlobId target;
+    private final ImmutableList<BlobSourceOption> sourceOptions;
+    private final ImmutableList<BlobTargetOption> targetOptions;
+
+    MoveBlobRequest(
+        BlobId source,
+        BlobId target,
+        ImmutableList<BlobSourceOption> sourceOptions,
+        ImmutableList<BlobTargetOption> targetOptions) {
+      this.source = source;
+      this.target = target;
+      this.sourceOptions = sourceOptions;
+      this.targetOptions = targetOptions;
+    }
+
+    public BlobId getSource() {
+      return source;
+    }
+
+    public BlobId getTarget() {
+      return target;
+    }
+
+    public List<BlobSourceOption> getSourceOptions() {
+      return sourceOptions;
+    }
+
+    public List<BlobTargetOption> getTargetOptions() {
+      return targetOptions;
+    }
+
+    public Builder toBuilder() {
+      return new Builder(source, target, sourceOptions, targetOptions);
+    }
+
+    public static Builder newBuilder() {
+      return new Builder();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof MoveBlobRequest)) {
+        return false;
+      }
+      MoveBlobRequest that = (MoveBlobRequest) o;
+      return Objects.equals(source, that.source)
+          && Objects.equals(target, that.target)
+          && Objects.equals(sourceOptions, that.sourceOptions)
+          && Objects.equals(targetOptions, that.targetOptions);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(source, target, sourceOptions, targetOptions);
+    }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this)
+          .add("source", source)
+          .add("target", target)
+          .add("sourceOptions", sourceOptions)
+          .add("targetOptions", targetOptions)
+          .toString();
+    }
+
+    public static final class Builder {
+
+      private BlobId source;
+      private BlobId target;
+      private ImmutableList<BlobSourceOption> sourceOptions;
+      private ImmutableList<BlobTargetOption> targetOptions;
+
+      private Builder() {
+        this(null, null, ImmutableList.of(), ImmutableList.of());
+      }
+
+      private Builder(
+          BlobId source,
+          BlobId target,
+          ImmutableList<BlobSourceOption> sourceOptions,
+          ImmutableList<BlobTargetOption> targetOptions) {
+        this.source = source;
+        this.target = target;
+        this.sourceOptions = sourceOptions;
+        this.targetOptions = targetOptions;
+      }
+
+      public Builder setSource(BlobId source) {
+        this.source = requireNonNull(source, "source must be non null");
+        return this;
+      }
+
+      public Builder setTarget(BlobId target) {
+        this.target = requireNonNull(target, "target must be non null");
+        return this;
+      }
+
+      public Builder setSourceOptions(Iterable<BlobSourceOption> sourceOptions) {
+        this.sourceOptions =
+            ImmutableList.copyOf(requireNonNull(sourceOptions, "sourceOptions must be non null"));
+        return this;
+      }
+
+      public Builder setTargetOptions(Iterable<BlobTargetOption> targetOptions) {
+        this.targetOptions =
+            ImmutableList.copyOf(requireNonNull(targetOptions, "targetOptions must be non null"));
+        return this;
+      }
+
+      public Builder setSourceOptions(BlobSourceOption... sourceOptions) {
+        this.sourceOptions =
+            ImmutableList.copyOf(requireNonNull(sourceOptions, "sourceOptions must be non null"));
+        return this;
+      }
+
+      public Builder setTargetOptions(BlobTargetOption... targetOptions) {
+        this.targetOptions =
+            ImmutableList.copyOf(requireNonNull(targetOptions, "targetOptions must be non null"));
+        return this;
+      }
+
+      public MoveBlobRequest build() {
+        return new MoveBlobRequest(
+            requireNonNull(source, "source must be non null"),
+            requireNonNull(target, "target must be non null"),
+            sourceOptions,
+            targetOptions);
+      }
+    }
+  }
+
   /**
    * Creates a new bucket.
    *
@@ -4882,4 +5028,16 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
   default BlobWriteSession blobWriteSession(BlobInfo blobInfo, BlobWriteOption... options) {
     return throwGrpcOnly(fmtMethodName("blobWriteSession", BlobInfo.class, BlobWriteOption.class));
   }
+
+  /**
+   * Atomically move an object from one name to another.
+   *
+   * <p>This new method is an atomic equivalent of the existing {@link Storage#copy(CopyRequest)} +
+   * {@link Storage#delete(BlobId)}, however without the ability to change metadata fields for the
+   * target object.
+   *
+   * @since 2.48.0
+   */
+  @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+  Blob moveBlob(MoveBlobRequest request);
 }
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 efb62745..e5b023a3 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
@@ -1696,6 +1696,27 @@ final class StorageImpl extends BaseService<StorageOptions> implements Storage,
     return BlobWriteSessions.of(writableByteChannelSession);
   }
 
+  @Override
+  public Blob moveBlob(MoveBlobRequest request) {
+    Opts<ObjectSourceOpt> srcOpts =
+        Opts.unwrap(request.getSourceOptions()).resolveFrom(request.getSource()).projectAsSource();
+    Opts<ObjectTargetOpt> dstOpts =
+        Opts.unwrap(request.getTargetOptions()).resolveFrom(request.getTarget());
+    ImmutableMap<StorageRpc.Option, ?> sourceOptions = srcOpts.getRpcOptions();
+    ImmutableMap<StorageRpc.Option, ?> targetOptions = dstOpts.getRpcOptions();
+
+    return run(
+        retryAlgorithmManager.getForObjectsMove(sourceOptions, targetOptions),
+        () ->
+            storageRpc.moveObject(
+                request.getSource().getBucket(),
+                request.getSource().getName(),
+                request.getTarget().getName(),
+                sourceOptions,
+                targetOptions),
+        o -> codecs.blobInfo().decode(o).asBlob(this));
+  }
+
   @Override
   public BlobInfo internalCreateFrom(Path path, BlobInfo info, Opts<ObjectTargetOpt> opts)
       throws IOException {
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 1f51429d..6c9828c1 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
@@ -48,6 +48,7 @@ import com.google.storage.v2.GetObjectRequest;
 import com.google.storage.v2.ListBucketsRequest;
 import com.google.storage.v2.ListObjectsRequest;
 import com.google.storage.v2.LockBucketRetentionPolicyRequest;
+import com.google.storage.v2.MoveObjectRequest;
 import com.google.storage.v2.ReadObjectRequest;
 import com.google.storage.v2.RestoreObjectRequest;
 import com.google.storage.v2.RewriteObjectRequest;
@@ -155,6 +156,10 @@ final class UnifiedOpts {
       return Mapper.identity();
     }
 
+    default Mapper<MoveObjectRequest.Builder> moveObject() {
+      return Mapper.identity();
+    }
+
     default Mapper<RestoreObjectRequest.Builder> restoreObject() {
       return Mapper.identity();
     }
@@ -193,6 +198,10 @@ final class UnifiedOpts {
       return Mapper.identity();
     }
 
+    default Mapper<MoveObjectRequest.Builder> moveObject() {
+      return Mapper.identity();
+    }
+
     default Mapper<StartResumableWriteRequest.Builder> startResumableWrite() {
       return Mapper.identity();
     }
@@ -295,6 +304,11 @@ final class UnifiedOpts {
     default Mapper<RewriteObjectRequest.Builder> rewriteObject() {
       return Mapper.identity();
     }
+
+    @Override
+    default Mapper<MoveObjectRequest.Builder> moveObject() {
+      return Mapper.identity();
+    }
   }
 
   /**
@@ -877,6 +891,11 @@ final class UnifiedOpts {
       return Mapper.identity();
     }
 
+    @Override
+    public Mapper<MoveObjectRequest.Builder> moveObject() {
+      return Mapper.identity();
+    }
+
     /**
      * Define a decoder which can clear out any fields which may have not been selected.
      *
@@ -1090,6 +1109,11 @@ final class UnifiedOpts {
       return b -> b.setIfGenerationMatch(val);
     }
 
+    @Override
+    public Mapper<MoveObjectRequest.Builder> moveObject() {
+      return b -> b.setIfGenerationMatch(val);
+    }
+
     @Override
     public SourceGenerationMatch asSource() {
       return new SourceGenerationMatch(val);
@@ -1154,6 +1178,11 @@ final class UnifiedOpts {
       return b -> b.setIfGenerationNotMatch(val);
     }
 
+    @Override
+    public Mapper<MoveObjectRequest.Builder> moveObject() {
+      return b -> b.setIfGenerationNotMatch(val);
+    }
+
     @Override
     public SourceGenerationNotMatch asSource() {
       return new SourceGenerationNotMatch(val);
@@ -1329,6 +1358,11 @@ final class UnifiedOpts {
       return b -> b.setIfMetagenerationMatch(val);
     }
 
+    @Override
+    public Mapper<MoveObjectRequest.Builder> moveObject() {
+      return b -> b.setIfMetagenerationMatch(val);
+    }
+
     @Override
     public Mapper<UpdateBucketRequest.Builder> updateBucket() {
       return b -> b.setIfMetagenerationMatch(val);
@@ -1417,6 +1451,11 @@ final class UnifiedOpts {
       return b -> b.setIfMetagenerationNotMatch(val);
     }
 
+    @Override
+    public Mapper<MoveObjectRequest.Builder> moveObject() {
+      return b -> b.setIfMetagenerationNotMatch(val);
+    }
+
     @Override
     public Mapper<UpdateBucketRequest.Builder> updateBucket() {
       return b -> b.setIfMetagenerationNotMatch(val);
@@ -1620,6 +1659,11 @@ final class UnifiedOpts {
     public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
       return b -> b.setIfSourceGenerationMatch(val);
     }
+
+    @Override
+    public Mapper<MoveObjectRequest.Builder> moveObject() {
+      return b -> b.setIfSourceGenerationMatch(val);
+    }
   }
 
   /**
@@ -1638,6 +1682,11 @@ final class UnifiedOpts {
     public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
       return b -> b.setIfSourceGenerationNotMatch(val);
     }
+
+    @Override
+    public Mapper<MoveObjectRequest.Builder> moveObject() {
+      return b -> b.setIfSourceGenerationNotMatch(val);
+    }
   }
 
   /**
@@ -1656,6 +1705,11 @@ final class UnifiedOpts {
     public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
       return b -> b.setIfSourceMetagenerationMatch(val);
     }
+
+    @Override
+    public Mapper<MoveObjectRequest.Builder> moveObject() {
+      return b -> b.setIfSourceMetagenerationMatch(val);
+    }
   }
 
   /**
@@ -1674,6 +1728,11 @@ final class UnifiedOpts {
     public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
       return b -> b.setIfSourceMetagenerationNotMatch(val);
     }
+
+    @Override
+    public Mapper<MoveObjectRequest.Builder> moveObject() {
+      return b -> b.setIfSourceMetagenerationNotMatch(val);
+    }
   }
 
   static final class RequestedPolicyVersion extends RpcOptVal<@NonNull Long>
@@ -1854,6 +1913,11 @@ final class UnifiedOpts {
     public Mapper<RewriteObjectRequest.Builder> rewriteObject() {
       return Mapper.identity();
     }
+
+    @Override
+    public Mapper<MoveObjectRequest.Builder> moveObject() {
+      return Mapper.identity();
+    }
   }
 
   static final class VersionsFilter extends RpcOptVal<@NonNull Boolean> implements ObjectListOpt {
@@ -2448,6 +2512,27 @@ final class UnifiedOpts {
           .reduce(Mapper.identity(), Mapper::andThen);
     }
 
+    Mapper<MoveObjectRequest.Builder> moveObjectsRequest() {
+      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.moveObject();
+                } else if (o instanceof ObjectSourceOpt) {
+                  ObjectSourceOpt oso = (ObjectSourceOpt) o;
+                  return oso.moveObject();
+                } 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<GetIamPolicyRequest.Builder> getIamPolicyRequest() {
       return fuseMappers(BucketSourceOpt.class, BucketSourceOpt::getIamPolicy);
     }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java
index 5341051a..c276c511 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java
@@ -46,6 +46,7 @@ import com.google.api.services.storage.Storage;
 import com.google.api.services.storage.Storage.Objects.Compose;
 import com.google.api.services.storage.Storage.Objects.Get;
 import com.google.api.services.storage.Storage.Objects.Insert;
+import com.google.api.services.storage.Storage.Objects.Move;
 import com.google.api.services.storage.model.Bucket;
 import com.google.api.services.storage.model.Bucket.RetentionPolicy;
 import com.google.api.services.storage.model.BucketAccessControl;
@@ -1143,6 +1144,42 @@ public class HttpStorageRpc implements StorageRpc {
     }
   }
 
+  @Override
+  public StorageObject moveObject(
+      String bucket,
+      String sourceObject,
+      String destinationObject,
+      Map<Option, ?> sourceOptions,
+      Map<Option, ?> targetOptions) {
+
+    String userProject = Option.USER_PROJECT.getString(sourceOptions);
+    if (userProject == null) {
+      userProject = Option.USER_PROJECT.getString(targetOptions);
+    }
+    try {
+      Move move =
+          storage
+              .objects()
+              .move(bucket, sourceObject, destinationObject)
+              .setIfSourceMetagenerationMatch(
+                  Option.IF_SOURCE_METAGENERATION_MATCH.getLong(sourceOptions))
+              .setIfSourceMetagenerationNotMatch(
+                  Option.IF_SOURCE_METAGENERATION_NOT_MATCH.getLong(sourceOptions))
+              .setIfSourceGenerationMatch(Option.IF_SOURCE_GENERATION_MATCH.getLong(sourceOptions))
+              .setIfSourceGenerationNotMatch(
+                  Option.IF_SOURCE_GENERATION_NOT_MATCH.getLong(sourceOptions))
+              .setIfMetagenerationMatch(Option.IF_METAGENERATION_MATCH.getLong(targetOptions))
+              .setIfMetagenerationNotMatch(
+                  Option.IF_METAGENERATION_NOT_MATCH.getLong(targetOptions))
+              .setIfGenerationMatch(Option.IF_GENERATION_MATCH.getLong(targetOptions))
+              .setIfGenerationNotMatch(Option.IF_GENERATION_NOT_MATCH.getLong(targetOptions))
+              .setUserProject(userProject);
+      return move.execute();
+    } catch (IOException e) {
+      throw translate(e);
+    }
+  }
+
   @Override
   public RewriteResponse openRewrite(RewriteRequest rewriteRequest) {
     Span span = startSpan(HttpStorageRpcSpans.SPAN_NAME_OPEN_REWRITE);
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/StorageRpc.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/StorageRpc.java
index 78747d42..ffd1766b 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/StorageRpc.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/StorageRpc.java
@@ -393,6 +393,13 @@ public interface StorageRpc extends ServiceRpc {
       int length,
       boolean last);
 
+  StorageObject moveObject(
+      String bucket,
+      String sourceObject,
+      String destinationObject,
+      Map<Option, ?> sourceOptions,
+      Map<Option, ?> targetOptions);
+
   /**
    * Sends a rewrite request to open a rewrite channel.
    *
diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml
index 47401724..33516257 100644
--- a/samples/snippets/pom.xml
+++ b/samples/snippets/pom.xml
@@ -31,7 +31,7 @@
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>libraries-bom</artifactId>
-        <version>26.52.0</version>
+        <version>26.53.0</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>


Test patch:
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/testing/StorageRpcTestBase.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/testing/StorageRpcTestBase.java
index 6686cb92..8f835f5b 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/testing/StorageRpcTestBase.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/testing/StorageRpcTestBase.java
@@ -322,6 +322,16 @@ public class StorageRpcTestBase implements StorageRpc {
     throw new UnsupportedOperationException("Not implemented yet");
   }
 
+  @Override
+  public StorageObject moveObject(
+      String bucket,
+      String sourceObject,
+      String destinationObject,
+      Map<Option, ?> sourceOptions,
+      Map<Option, ?> targetOptions) {
+    throw new UnsupportedOperationException("Not implemented yet");
+  }
+
   @Override
   public Storage getStorage() {
     throw new UnsupportedOperationException("Not implemented yet");
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITFoldersTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITFoldersTest.java
index a5953366..1eceea6c 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITFoldersTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITFoldersTest.java
@@ -19,14 +19,24 @@ package com.google.cloud.storage.it;
 import static com.google.cloud.storage.TestUtils.assertAll;
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.cloud.storage.Blob;
+import com.google.cloud.storage.BlobId;
+import com.google.cloud.storage.BlobInfo;
 import com.google.cloud.storage.BucketInfo;
+import com.google.cloud.storage.DataGenerator;
+import com.google.cloud.storage.Storage;
+import com.google.cloud.storage.Storage.BlobSourceOption;
+import com.google.cloud.storage.Storage.BlobTargetOption;
+import com.google.cloud.storage.Storage.MoveBlobRequest;
+import com.google.cloud.storage.TransportCompatibility.Transport;
 import com.google.cloud.storage.it.runner.StorageITRunner;
 import com.google.cloud.storage.it.runner.annotations.Backend;
 import com.google.cloud.storage.it.runner.annotations.BucketFixture;
 import com.google.cloud.storage.it.runner.annotations.BucketType;
+import com.google.cloud.storage.it.runner.annotations.CrossRun;
 import com.google.cloud.storage.it.runner.annotations.Inject;
-import com.google.cloud.storage.it.runner.annotations.SingleBackend;
 import com.google.cloud.storage.it.runner.registry.Generator;
+import com.google.common.collect.ImmutableMap;
 import com.google.storage.control.v2.BucketName;
 import com.google.storage.control.v2.CreateFolderRequest;
 import com.google.storage.control.v2.Folder;
@@ -36,10 +46,13 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 
 @RunWith(StorageITRunner.class)
-@SingleBackend(Backend.PROD)
+@CrossRun(
+    backends = Backend.PROD,
+    transports = {Transport.HTTP, Transport.GRPC})
 public class ITFoldersTest {
 
   @Inject public StorageControlClient ctrl;
+  @Inject public Storage storage;
 
   @Inject
   @BucketFixture(BucketType.HNS)
@@ -61,4 +74,30 @@ public class ITFoldersTest {
         () -> assertThat(folder.getName()).isEqualTo(FolderName.format("_", bucketName, folderId)),
         () -> assertThat(folder.getMetageneration()).isGreaterThan(0));
   }
+
+  @Test
+  public void moveObject() throws Exception {
+    ChecksummedTestContent testContent =
+        ChecksummedTestContent.of(DataGenerator.base64Characters().genBytes(5286));
+
+    BlobId id1 = BlobId.of(bucket.getName(), generator.randomObjectName());
+    BlobId id2 = BlobId.of(bucket.getName(), generator.randomObjectName());
+
+    ImmutableMap<String, String> metadata = ImmutableMap.of("a", "b", "c", "d");
+    BlobInfo info1 = BlobInfo.newBuilder(id1).setMetadata(metadata).build();
+    Blob blob1 = storage.create(info1, testContent.getBytes(), BlobTargetOption.doesNotExist());
+
+    Blob blob2 =
+        storage.moveBlob(
+            MoveBlobRequest.newBuilder()
+                .setSource(blob1.getBlobId())
+                .setTarget(id2)
+                .setSourceOptions(BlobSourceOption.generationMatch())
+                .setTargetOptions(BlobTargetOption.doesNotExist())
+                .build());
+
+    assertAll(
+        () -> assertThat(blob2.getCrc32c()).isEqualTo(testContent.getCrc32cBase64()),
+        () -> assertThat(blob2.getMetadata()).isEqualTo(metadata));
+  }
 }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITUniverseDomainTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITUniverseDomainTest.java
index ede5d1a0..a0fc1a1f 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITUniverseDomainTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITUniverseDomainTest.java
@@ -18,6 +18,7 @@ package com.google.cloud.storage.it;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assume.assumeNotNull;
 
 import com.google.auth.oauth2.GoogleCredentials;
 import com.google.auth.oauth2.ServiceAccountCredentials;
@@ -43,6 +44,10 @@ public class ITUniverseDomainTest {
 
   @BeforeClass
   public static void setUp() throws Exception {
+    assumeNotNull(TEST_UNIVERSE_DOMAIN);
+    assumeNotNull(TEST_PROJECT_ID);
+    assumeNotNull(TEST_UNIVERSE_LOCATION);
+    assumeNotNull(CREDENTIAL_PATH);
     GoogleCredentials creds =
         ServiceAccountCredentials.fromStream(Files.newInputStream(Paths.get(CREDENTIAL_PATH)))
             .toBuilder()
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/AbstractStorageProxy.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/AbstractStorageProxy.java
index 9e5e9691..f0dc3f34 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/AbstractStorageProxy.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/AbstractStorageProxy.java
@@ -494,6 +494,11 @@ abstract class AbstractStorageProxy implements Storage {
     delegate.close();
   }
 
+  @Override
+  public Blob moveBlob(MoveBlobRequest request) {
+    return delegate.moveBlob(request);
+  }
+
   @Override
   public StorageOptions getOptions() {
     return delegate.getOptions();
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BackendResources.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BackendResources.java
index 012a404a..c2193bf5 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BackendResources.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BackendResources.java
@@ -87,7 +87,7 @@ final class BackendResources implements ManagedLifecycle {
     ProtectedBucketNames protectedBucketNames = new ProtectedBucketNames();
     TestRunScopedInstance<StorageInstance> storageJson =
         TestRunScopedInstance.of(
-            "STORAGE_JSON_" + backend.name(),
+            "fixture/STORAGE/[JSON][" + backend.name() + "]",
             () -> {
               HttpStorageOptions.Builder optionsBuilder;
               switch (backend) {
@@ -108,7 +108,7 @@ final class BackendResources implements ManagedLifecycle {
             });
     TestRunScopedInstance<StorageInstance> storageGrpc =
         TestRunScopedInstance.of(
-            "STORAGE_GRPC_" + backend.name(),
+            "fixture/STORAGE/[GRPC][" + backend.name() + "]",
             () -> {
               GrpcStorageOptions.Builder optionsBuilder;
               switch (backend) {
@@ -137,7 +137,7 @@ final class BackendResources implements ManagedLifecycle {
             });
     TestRunScopedInstance<StorageControlInstance> ctrl =
         TestRunScopedInstance.of(
-            "STORAGE_CONTROL_" + backend.name(),
+            "fixture/STORAGE_CONTROL/[" + backend.name() + "]",
             () -> {
               StorageControlSettings.Builder builder;
               switch (backend) {
@@ -176,7 +176,7 @@ final class BackendResources implements ManagedLifecycle {
             });
     TestRunScopedInstance<BucketInfoShim> bucket =
         TestRunScopedInstance.of(
-            "BUCKET_" + backend.name(),
+            "fixture/BUCKET/[" + backend.name() + "]",
             () -> {
               String bucketName = String.format("java-storage-grpc-%s", UUID.randomUUID());
               protectedBucketNames.add(bucketName);
@@ -185,7 +185,7 @@ final class BackendResources implements ManagedLifecycle {
             });
     TestRunScopedInstance<BucketInfoShim> bucketRp =
         TestRunScopedInstance.of(
-            "BUCKET_REQUESTER_PAYS_" + backend.name(),
+            "fixture/BUCKET/[" + backend.name() + "]/REQUESTER_PAYS",
             () -> {
               String bucketName = String.format("java-storage-grpc-rp-%s", UUID.randomUUID());
               protectedBucketNames.add(bucketName);
@@ -196,7 +196,7 @@ final class BackendResources implements ManagedLifecycle {
             });
     TestRunScopedInstance<BucketInfoShim> bucketVersioned =
         TestRunScopedInstance.of(
-            "BUCKET_VERSIONED_" + backend.name(),
+            "fixture/BUCKET/[" + backend.name() + "]/VERSIONED",
             () -> {
               String bucketName = String.format("java-storage-grpc-v-%s", UUID.randomUUID());
               protectedBucketNames.add(bucketName);
@@ -207,7 +207,7 @@ final class BackendResources implements ManagedLifecycle {
             });
     TestRunScopedInstance<BucketInfoShim> bucketHns =
         TestRunScopedInstance.of(
-            "BUCKET_HNS_" + backend.name(),
+            "fixture/BUCKET/[" + backend.name() + "]/HNS",
             () -> {
               String bucketName = String.format("java-storage-grpc-hns-%s", UUID.randomUUID());
               protectedBucketNames.add(bucketName);
@@ -225,22 +225,23 @@ final class BackendResources implements ManagedLifecycle {
             });
     TestRunScopedInstance<ObjectsFixture> objectsFixture =
         TestRunScopedInstance.of(
-            "OBJECTS_FIXTURE_" + backend.name(),
+            "fixture/OBJECTS/[" + backend.name() + "]",
             () -> new ObjectsFixture(storageJson.get().getStorage(), bucket.get().getBucketInfo()));
     TestRunScopedInstance<ObjectsFixture> objectsFixtureRp =
         TestRunScopedInstance.of(
-            "OBJECTS_FIXTURE_REQUESTER_PAYS_" + backend.name(),
+            "fixture/OBJECTS/[" + backend.name() + "]/REQUESTER_PAYS",
             () ->
                 new ObjectsFixture(storageJson.get().getStorage(), bucketRp.get().getBucketInfo()));
     TestRunScopedInstance<ObjectsFixture> objectsFixtureHns =
         TestRunScopedInstance.of(
-            "OBJECTS_FIXTURE_HNS_" + backend.name(),
+            "fixture/OBJECTS/[" + backend.name() + "]/HNS",
             () ->
                 new ObjectsFixture(
                     storageJson.get().getStorage(), bucketHns.get().getBucketInfo()));
     TestRunScopedInstance<KmsFixture> kmsFixture =
         TestRunScopedInstance.of(
-            "KMS_FIXTURE_" + backend.name(), () -> KmsFixture.of(storageJson.get().getStorage()));
+            "fixture/KMS/[" + backend.name() + "]",
+            () -> KmsFixture.of(storageJson.get().getStorage()));
 
     return new BackendResources(
         backend,
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/Registry.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/Registry.java
index cf0a3cb8..23b15f0c 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/Registry.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/Registry.java
@@ -73,14 +73,14 @@ public final class Registry extends RunListener {
               new ThreadFactoryBuilder().setDaemon(true).setNameFormat("test-run-%d").build()));
 
   private final TestRunScopedInstance<TestBench> testBench =
-      TestRunScopedInstance.of("TEST_BENCH", () -> TestBench.newBuilder().build());
+      TestRunScopedInstance.of("fixture/TEST_BENCH", () -> TestBench.newBuilder().build());
 
   private final TestRunScopedInstance<Generator> generator =
-      TestRunScopedInstance.of("GENERATOR", Generator::new);
+      TestRunScopedInstance.of("fixture/GENERATOR", Generator::new);
 
   final TestRunScopedInstance<OtelSdkShim> otelSdk =
       TestRunScopedInstance.of(
-          "OTEL_SDK",
+          "fixture/OTEL_SDK",
           () -> {
             String projectId = StorageOptions.getDefaultInstance().getProjectId();
             return new OtelSdkShim(projectId);
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/TestRunScopedInstance.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/TestRunScopedInstance.java
index a503b067..5112a382 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/TestRunScopedInstance.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/TestRunScopedInstance.java
@@ -53,7 +53,7 @@ final class TestRunScopedInstance<T extends ManagedLifecycle> implements Supplie
         if (instance == null) {
           // if we don't short-circuit for OTEL_SDK we will cause a stack overflow, because we would
           // be trying to get our instance to record that we're starting our instance.
-          if (name.equals("OTEL_SDK")) {
+          if (name.equals("fixture/OTEL_SDK")) {
             T tmp = ctor.get();
             tmp.start();
             instance = tmp;
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpRpcContextTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpRpcContextTest.java
index 3e8b295b..bb049137 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpRpcContextTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpRpcContextTest.java
@@ -21,6 +21,7 @@ import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
 import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.cloud.NoCredentials;
 import com.google.cloud.TransportOptions;
 import com.google.cloud.Tuple;
 import com.google.cloud.WriteChannel;
@@ -66,6 +67,8 @@ public class HttpRpcContextTest {
     Storage service =
         StorageOptions.getDefaultInstance()
             .toBuilder()
+            .setProjectId("test-project")
+            .setCredentials(NoCredentials.getInstance())
             .setTransportOptions(transportOptions)
             .build()
             .getService();
diff --git a/google-cloud-storage/src/test/resources/com/google/cloud/storage/it/runner/registry/Dockerfile b/google-cloud-storage/src/test/resources/com/google/cloud/storage/it/runner/registry/Dockerfile
index 0c005975..dfde7c96 100644
--- a/google-cloud-storage/src/test/resources/com/google/cloud/storage/it/runner/registry/Dockerfile
+++ b/google-cloud-storage/src/test/resources/com/google/cloud/storage/it/runner/registry/Dockerfile
@@ -1,1 +1,1 @@
-FROM gcr.io/cloud-devrel-public-resources/storage-testbench:v0.51.0
+FROM gcr.io/cloud-devrel-public-resources/storage-testbench:v0.52.0