Repository: googleapis/java-storage
Pull request #2403 (2024-02-08 23:15:27)

feat: add soft delete feature
Implements the soft delete feature as requested in go/gcs-soft-delete-client-request

Also adds the `get(String blob, Long generation, BlobGetOption...options)` method signature to Bucket.java. It's already present in Storage.java, I'm adding it now because it's necessary to get a soft deleted object. 


Patch:
diff --git a/google-cloud-storage/clirr-ignored-differences.xml b/google-cloud-storage/clirr-ignored-differences.xml
index 635e882a..7fffdb6b 100644
--- a/google-cloud-storage/clirr-ignored-differences.xml
+++ b/google-cloud-storage/clirr-ignored-differences.xml
@@ -27,6 +27,24 @@
     <method>com.google.cloud.storage.BlobInfo$Builder setRetention(com.google.cloud.storage.BlobInfo$Retention)</method>
   </difference>
 
+  <difference>
+    <differenceType>7013</differenceType>
+    <className>com/google/cloud/storage/BucketInfo$Builder</className>
+    <method>com.google.cloud.storage.BucketInfo$Builder setSoftDeletePolicy(com.google.cloud.storage.BucketInfo$SoftDeletePolicy)</method>
+  </difference>
+
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/storage/Storage</className>
+    <method>com.google.cloud.storage.Blob restore(com.google.cloud.storage.BlobId, com.google.cloud.storage.Storage$BlobRestoreOption[])</method>
+  </difference>
+
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/storage/spi/v1/StorageRpc</className>
+    <method>com.google.api.services.storage.model.StorageObject restore(com.google.api.services.storage.model.StorageObject, java.util.Map)</method>
+  </difference>
+
   <!-- @BetaApi members -->
   <difference>
     <differenceType>7009</differenceType>
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Blob.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Blob.java
index e6295b8c..c6ea7a2c 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Blob.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Blob.java
@@ -526,6 +526,18 @@ public class Blob extends BlobInfo {
       return this;
     }
 
+    @Override
+    Builder setSoftDeleteTime(OffsetDateTime softDeleteTime) {
+      infoBuilder.setSoftDeleteTime(softDeleteTime);
+      return this;
+    }
+
+    @Override
+    Builder setHardDeleteTime(OffsetDateTime hardDeleteTime) {
+      infoBuilder.setHardDeleteTime(hardDeleteTime);
+      return this;
+    }
+
     @Override
     public Builder setRetention(Retention retention) {
       infoBuilder.setRetention(retention);
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobInfo.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobInfo.java
index e808f444..52ce09fd 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobInfo.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobInfo.java
@@ -108,6 +108,8 @@ public class BlobInfo implements Serializable {
   private final Boolean temporaryHold;
   private final OffsetDateTime retentionExpirationTime;
   private final Retention retention;
+  private final OffsetDateTime softDeleteTime;
+  private final OffsetDateTime hardDeleteTime;
   private final transient ImmutableSet<NamedField> modifiedFields;
 
   /** This class is meant for internal use only. Users are discouraged from using this class. */
@@ -525,6 +527,10 @@ public class BlobInfo implements Serializable {
       return setRetentionExpirationTime(millisOffsetDateTimeCodec.decode(retentionExpirationTime));
     }
 
+    abstract Builder setSoftDeleteTime(OffsetDateTime offsetDateTime);
+
+    abstract Builder setHardDeleteTime(OffsetDateTime hardDeleteTIme);
+
     public abstract Builder setRetention(Retention retention);
 
     /** Creates a {@code BlobInfo} object. */
@@ -626,6 +632,8 @@ public class BlobInfo implements Serializable {
     private Boolean temporaryHold;
     private OffsetDateTime retentionExpirationTime;
     private Retention retention;
+    private OffsetDateTime softDeleteTime;
+    private OffsetDateTime hardDeleteTime;
     private final ImmutableSet.Builder<NamedField> modifiedFields = ImmutableSet.builder();
 
     BuilderImpl(BlobId blobId) {
@@ -664,6 +672,8 @@ public class BlobInfo implements Serializable {
       temporaryHold = blobInfo.temporaryHold;
       retentionExpirationTime = blobInfo.retentionExpirationTime;
       retention = blobInfo.retention;
+      softDeleteTime = blobInfo.softDeleteTime;
+      hardDeleteTime = blobInfo.hardDeleteTime;
     }
 
     @Override
@@ -1037,6 +1047,24 @@ public class BlobInfo implements Serializable {
       return this;
     }
 
+    @Override
+    Builder setSoftDeleteTime(OffsetDateTime softDeleteTime) {
+      if (!Objects.equals(this.softDeleteTime, softDeleteTime)) {
+        modifiedFields.add(BlobField.SOFT_DELETE_TIME);
+      }
+      this.softDeleteTime = softDeleteTime;
+      return this;
+    }
+
+    @Override
+    Builder setHardDeleteTime(OffsetDateTime hardDeleteTime) {
+      if (!Objects.equals(this.hardDeleteTime, hardDeleteTime)) {
+        modifiedFields.add(BlobField.HARD_DELETE_TIME);
+      }
+      this.hardDeleteTime = hardDeleteTime;
+      return this;
+    }
+
     @Override
     public Builder setRetention(Retention retention) {
       // todo: b/308194853
@@ -1269,6 +1297,8 @@ public class BlobInfo implements Serializable {
     temporaryHold = builder.temporaryHold;
     retentionExpirationTime = builder.retentionExpirationTime;
     retention = builder.retention;
+    softDeleteTime = builder.softDeleteTime;
+    hardDeleteTime = builder.hardDeleteTime;
     modifiedFields = builder.modifiedFields.build();
   }
 
@@ -1662,6 +1692,18 @@ public class BlobInfo implements Serializable {
     return retentionExpirationTime;
   }
 
+  /** If this object has been soft-deleted, returns the time it was soft-deleted. */
+  public OffsetDateTime getSoftDeleteTime() {
+    return softDeleteTime;
+  }
+
+  /**
+   * If this object has been soft-deleted, returns the time at which it will be permanently deleted.
+   */
+  public OffsetDateTime getHardDeleteTime() {
+    return hardDeleteTime;
+  }
+
   /** Returns the object's Retention policy. */
   public Retention getRetention() {
     return retention;
@@ -1717,7 +1759,9 @@ public class BlobInfo implements Serializable {
         eventBasedHold,
         temporaryHold,
         retention,
-        retentionExpirationTime);
+        retentionExpirationTime,
+        softDeleteTime,
+        hardDeleteTime);
   }
 
   @Override
@@ -1759,7 +1803,9 @@ public class BlobInfo implements Serializable {
         && Objects.equals(eventBasedHold, blobInfo.eventBasedHold)
         && Objects.equals(temporaryHold, blobInfo.temporaryHold)
         && Objects.equals(retentionExpirationTime, blobInfo.retentionExpirationTime)
-        && Objects.equals(retention, blobInfo.retention);
+        && Objects.equals(retention, blobInfo.retention)
+        && Objects.equals(softDeleteTime, blobInfo.softDeleteTime)
+        && Objects.equals(hardDeleteTime, blobInfo.hardDeleteTime);
   }
 
   ImmutableSet<NamedField> getModifiedFields() {
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Bucket.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Bucket.java
index 6459e0a0..0587bb0b 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Bucket.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Bucket.java
@@ -748,6 +748,12 @@ public class Bucket extends BucketInfo {
       return this;
     }
 
+    @Override
+    public Builder setSoftDeletePolicy(SoftDeletePolicy softDeletePolicy) {
+      infoBuilder.setSoftDeletePolicy(softDeletePolicy);
+      return this;
+    }
+
     @Override
     public Builder setHierarchicalNamespace(HierarchicalNamespace hierarchicalNamespace) {
       infoBuilder.setHierarchicalNamespace(hierarchicalNamespace);
@@ -1089,6 +1095,28 @@ public class Bucket extends BucketInfo {
     return storage.get(BlobId.of(getName(), blob), options);
   }
 
+  /**
+   * Returns the requested blob in this bucket of a specific generation or {@code null} if not
+   * found.
+   *
+   * <p>Example of getting a blob of a specific in the bucket.
+   *
+   * <pre>{@code
+   * String blobName = "my_blob_name";
+   * long generation = 42;
+   * Blob blob = bucket.get(blobName, generation);
+   * }</pre>
+   *
+   * @param blob name of the requested blob
+   * @param generation the generation to get
+   * @param options blob search options
+   * @throws StorageException upon failure
+   */
+  @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+  public Blob get(String blob, Long generation, BlobGetOption... options) {
+    return storage.get(BlobId.of(getName(), blob, generation), options);
+  }
+
   /**
    * Returns a list of requested blobs in this bucket. Blobs that do not exist are null.
    *
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java
index af3e4436..32355592 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java
@@ -120,6 +120,8 @@ public class BucketInfo implements Serializable {
   private final ObjectRetention objectRetention;
   private final HierarchicalNamespace hierarchicalNamespace;
 
+  private final SoftDeletePolicy softDeletePolicy;
+
   private final transient ImmutableSet<NamedField> modifiedFields;
 
   /**
@@ -350,6 +352,90 @@ public class BucketInfo implements Serializable {
     }
   }
 
+  /**
+   * The bucket's soft delete policy. If this policy is set, any deleted objects will be
+   * soft-deleted according to the time specified in the policy
+   */
+  public static class SoftDeletePolicy implements Serializable {
+
+    private static final long serialVersionUID = -8100190443052242908L;
+    private Duration retentionDuration;
+    private OffsetDateTime effectiveTime;
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof SoftDeletePolicy)) {
+        return false;
+      }
+      SoftDeletePolicy that = (SoftDeletePolicy) o;
+      return Objects.equals(retentionDuration, that.retentionDuration)
+          && Objects.equals(effectiveTime, that.effectiveTime);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(retentionDuration, effectiveTime);
+    }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this)
+          .add("retentionDuration", retentionDuration)
+          .add("effectiveTime", effectiveTime)
+          .toString();
+    }
+
+    public static Builder newBuilder() {
+      return new Builder();
+    }
+
+    public Builder toBuilder() {
+      return new Builder().setRetentionDuration(retentionDuration).setEffectiveTime(effectiveTime);
+    }
+
+    private SoftDeletePolicy() {}
+
+    public SoftDeletePolicy(Builder builder) {
+      this.retentionDuration = builder.retentionDuration;
+      this.effectiveTime = builder.effectiveTime;
+    }
+
+    public Duration getRetentionDuration() {
+      return retentionDuration;
+    }
+
+    public OffsetDateTime getEffectiveTime() {
+      return effectiveTime;
+    }
+
+    public static final class Builder {
+      private Duration retentionDuration;
+      private OffsetDateTime effectiveTime;
+
+      /** Sets the length of time to retain soft-deleted objects for, expressed as a Duration */
+      public Builder setRetentionDuration(Duration retentionDuration) {
+        this.retentionDuration = retentionDuration;
+        return this;
+      }
+
+      /**
+       * Sets the time from which this soft-delete policy is effective. This is package-private
+       * because it can only be set by the backend.
+       */
+      Builder setEffectiveTime(OffsetDateTime effectiveTime) {
+        this.effectiveTime = effectiveTime;
+        return this;
+      }
+
+      public SoftDeletePolicy build() {
+        return new SoftDeletePolicy(this);
+      }
+    }
+  }
+
   /**
    * Configuration for the Autoclass settings of a bucket.
    *
@@ -1753,6 +1839,8 @@ public class BucketInfo implements Serializable {
 
     abstract Builder setObjectRetention(ObjectRetention objectRetention);
 
+    public abstract Builder setSoftDeletePolicy(SoftDeletePolicy softDeletePolicy);
+
     /** Creates a {@code BucketInfo} object. */
     public abstract BucketInfo build();
 
@@ -1851,6 +1939,8 @@ public class BucketInfo implements Serializable {
     private Logging logging;
     private CustomPlacementConfig customPlacementConfig;
     private ObjectRetention objectRetention;
+
+    private SoftDeletePolicy softDeletePolicy;
     private HierarchicalNamespace hierarchicalNamespace;
     private final ImmutableSet.Builder<NamedField> modifiedFields = ImmutableSet.builder();
 
@@ -1891,6 +1981,7 @@ public class BucketInfo implements Serializable {
       logging = bucketInfo.logging;
       customPlacementConfig = bucketInfo.customPlacementConfig;
       objectRetention = bucketInfo.objectRetention;
+      softDeletePolicy = bucketInfo.softDeletePolicy;
       hierarchicalNamespace = bucketInfo.hierarchicalNamespace;
     }
 
@@ -2257,6 +2348,15 @@ public class BucketInfo implements Serializable {
       return this;
     }
 
+    @Override
+    public Builder setSoftDeletePolicy(SoftDeletePolicy softDeletePolicy) {
+      if (!Objects.equals(this.softDeletePolicy, softDeletePolicy)) {
+        modifiedFields.add(BucketField.SOFT_DELETE_POLICY);
+      }
+      this.softDeletePolicy = softDeletePolicy;
+      return this;
+    }
+
     @Override
     public Builder setHierarchicalNamespace(HierarchicalNamespace hierarchicalNamespace) {
       if (!Objects.equals(this.hierarchicalNamespace, hierarchicalNamespace)) {
@@ -2507,6 +2607,7 @@ public class BucketInfo implements Serializable {
     logging = builder.logging;
     customPlacementConfig = builder.customPlacementConfig;
     objectRetention = builder.objectRetention;
+    softDeletePolicy = builder.softDeletePolicy;
     hierarchicalNamespace = builder.hierarchicalNamespace;
     modifiedFields = builder.modifiedFields.build();
   }
@@ -2848,6 +2949,11 @@ public class BucketInfo implements Serializable {
     return objectRetention;
   }
 
+  /** returns the Soft Delete policy */
+  public SoftDeletePolicy getSoftDeletePolicy() {
+    return softDeletePolicy;
+  }
+
   /** Returns the Hierarchical Namespace (Folders) Configuration */
   public HierarchicalNamespace getHierarchicalNamespace() {
     return hierarchicalNamespace;
@@ -2890,6 +2996,7 @@ public class BucketInfo implements Serializable {
         autoclass,
         locationType,
         objectRetention,
+        softDeletePolicy,
         hierarchicalNamespace,
         logging);
   }
@@ -2932,6 +3039,7 @@ public class BucketInfo implements Serializable {
         && Objects.equals(autoclass, that.autoclass)
         && Objects.equals(locationType, that.locationType)
         && Objects.equals(objectRetention, that.objectRetention)
+        && Objects.equals(softDeletePolicy, that.softDeletePolicy)
         && Objects.equals(hierarchicalNamespace, that.hierarchicalNamespace)
         && Objects.equals(logging, that.logging);
   }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcConversions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcConversions.java
index d1084bc4..28c51873 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcConversions.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcConversions.java
@@ -16,6 +16,7 @@
 
 package com.google.cloud.storage;
 
+import static com.google.cloud.storage.Storage.BucketField.SOFT_DELETE_POLICY;
 import static com.google.cloud.storage.Utils.bucketNameCodec;
 import static com.google.cloud.storage.Utils.ifNonNull;
 import static com.google.cloud.storage.Utils.lift;
@@ -90,6 +91,9 @@ final class GrpcConversions {
       Codec.of(this::iamConfigEncode, this::iamConfigDecode);
   private final Codec<BucketInfo.Autoclass, Bucket.Autoclass> autoclassCodec =
       Codec.of(this::autoclassEncode, this::autoclassDecode);
+
+  private final Codec<BucketInfo.SoftDeletePolicy, Bucket.SoftDeletePolicy> softDeletePolicyCodec =
+      Codec.of(this::softDeletePolicyEncode, this::softDeletePolicyDecode);
   private final Codec<BucketInfo.LifecycleRule, Bucket.Lifecycle.Rule> lifecycleRuleCodec =
       Codec.of(this::lifecycleRuleEncode, this::lifecycleRuleDecode);
   private final Codec<BucketInfo, Bucket> bucketInfoCodec =
@@ -294,6 +298,9 @@ final class GrpcConversions {
     if (from.hasAutoclass()) {
       to.setAutoclass(autoclassCodec.decode(from.getAutoclass()));
     }
+    if (from.hasSoftDeletePolicy()) {
+      to.setSoftDeletePolicy(softDeletePolicyCodec.decode(from.getSoftDeletePolicy()));
+    }
     if (from.hasCustomPlacementConfig()) {
       Bucket.CustomPlacementConfig customPlacementConfig = from.getCustomPlacementConfig();
       to.setCustomPlacementConfig(
@@ -383,6 +390,11 @@ final class GrpcConversions {
     ifNonNull(from.getAcl(), toImmutableListOf(bucketAclCodec::encode), to::addAllAcl);
     ifNonNull(from.getIamConfiguration(), iamConfigurationCodec::encode, to::setIamConfig);
     ifNonNull(from.getAutoclass(), autoclassCodec::encode, to::setAutoclass);
+    ifNonNull(from.getSoftDeletePolicy(), softDeletePolicyCodec::encode, to::setSoftDeletePolicy);
+    if (from.getModifiedFields().contains(SOFT_DELETE_POLICY)
+        && from.getSoftDeletePolicy() == null) {
+      to.clearSoftDeletePolicy();
+    }
     CustomPlacementConfig customPlacementConfig = from.getCustomPlacementConfig();
     if (customPlacementConfig != null && customPlacementConfig.getDataLocations() != null) {
       to.setCustomPlacementConfig(
@@ -601,6 +613,19 @@ final class GrpcConversions {
     return to.build();
   }
 
+  private BucketInfo.SoftDeletePolicy softDeletePolicyDecode(Bucket.SoftDeletePolicy from) {
+    BucketInfo.SoftDeletePolicy.Builder to = BucketInfo.SoftDeletePolicy.newBuilder();
+    ifNonNull(from.getRetentionDuration(), durationCodec::decode, to::setRetentionDuration);
+    ifNonNull(from.getEffectiveTime(), timestampCodec::decode, to::setEffectiveTime);
+    return to.build();
+  }
+
+  private Bucket.SoftDeletePolicy softDeletePolicyEncode(BucketInfo.SoftDeletePolicy from) {
+    Bucket.SoftDeletePolicy.Builder to = Bucket.SoftDeletePolicy.newBuilder();
+    ifNonNull(from.getRetentionDuration(), durationCodec::encode, to::setRetentionDuration);
+    return to.build();
+  }
+
   private Bucket.HierarchicalNamespace hierarchicalNamespaceEncode(
       BucketInfo.HierarchicalNamespace from) {
     Bucket.HierarchicalNamespace.Builder to = Bucket.HierarchicalNamespace.newBuilder();
@@ -863,6 +888,8 @@ final class GrpcConversions {
     ifNonNull(from.getUpdateTimeOffsetDateTime(), timestampCodec::encode, toBuilder::setUpdateTime);
     ifNonNull(from.getCreateTimeOffsetDateTime(), timestampCodec::encode, toBuilder::setCreateTime);
     ifNonNull(from.getCustomTimeOffsetDateTime(), timestampCodec::encode, toBuilder::setCustomTime);
+    ifNonNull(from.getSoftDeleteTime(), timestampCodec::encode, toBuilder::setSoftDeleteTime);
+    ifNonNull(from.getHardDeleteTime(), timestampCodec::encode, toBuilder::setHardDeleteTime);
     ifNonNull(
         from.getCustomerEncryption(),
         customerEncryptionCodec::encode,
@@ -928,6 +955,12 @@ final class GrpcConversions {
     if (from.hasCustomerEncryption()) {
       toBuilder.setCustomerEncryption(customerEncryptionCodec.decode(from.getCustomerEncryption()));
     }
+    if (from.hasSoftDeleteTime()) {
+      toBuilder.setSoftDeleteTime(timestampCodec.decode(from.getSoftDeleteTime()));
+    }
+    if (from.hasHardDeleteTime()) {
+      toBuilder.setHardDeleteTime(timestampCodec.decode(from.getHardDeleteTime()));
+    }
     String storageClass = from.getStorageClass();
     if (!storageClass.isEmpty()) {
       toBuilder.setStorageClass(StorageClass.valueOf(storageClass));
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 ea481ab2..de7af195 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
@@ -41,6 +41,7 @@ import com.google.storage.v2.ListObjectsRequest;
 import com.google.storage.v2.LockBucketRetentionPolicyRequest;
 import com.google.storage.v2.QueryWriteStatusRequest;
 import com.google.storage.v2.ReadObjectRequest;
+import com.google.storage.v2.RestoreObjectRequest;
 import com.google.storage.v2.RewriteObjectRequest;
 import com.google.storage.v2.StartResumableWriteRequest;
 import com.google.storage.v2.UpdateBucketRequest;
@@ -123,6 +124,10 @@ final class GrpcRetryAlgorithmManager implements Serializable {
     return retryStrategy.getIdempotentHandler();
   }
 
+  public ResultRetryAlgorithm<?> getFor(RestoreObjectRequest req) {
+    return retryStrategy.getIdempotentHandler();
+  }
+
   public ResultRetryAlgorithm<?> getFor(GetServiceAccountRequest req) {
     return retryStrategy.getIdempotentHandler();
   }
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 53c73d4d..e9857e93 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
@@ -112,6 +112,7 @@ import com.google.storage.v2.NotificationConfigName;
 import com.google.storage.v2.Object;
 import com.google.storage.v2.ObjectAccessControl;
 import com.google.storage.v2.ReadObjectRequest;
+import com.google.storage.v2.RestoreObjectRequest;
 import com.google.storage.v2.RewriteObjectRequest;
 import com.google.storage.v2.RewriteResponse;
 import com.google.storage.v2.StorageClient;
@@ -403,6 +404,33 @@ final class GrpcStorageImpl extends BaseService<StorageOptions>
     return get(blob, new BlobGetOption[0]);
   }
 
+  @Override
+  public Blob restore(BlobId blob, BlobRestoreOption... options) {
+    Opts<ObjectSourceOpt> unwrap = Opts.unwrap(options);
+    return internalObjectRestore(blob, unwrap);
+  }
+
+  private Blob internalObjectRestore(BlobId blobId, Opts<ObjectSourceOpt> opts) {
+    Opts<ObjectSourceOpt> finalOpts = opts.prepend(defaultOpts).prepend(ALL_BLOB_FIELDS);
+    GrpcCallContext grpcCallContext =
+        finalOpts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
+    RestoreObjectRequest.Builder builder =
+        RestoreObjectRequest.newBuilder()
+            .setBucket(bucketNameCodec.encode(blobId.getBucket()))
+            .setObject(blobId.getName());
+    ifNonNull(blobId.getGeneration(), builder::setGeneration);
+    RestoreObjectRequest req = finalOpts.restoreObjectRequest().apply(builder).build();
+    GrpcCallContext merge = Utils.merge(grpcCallContext, Retrying.newCallContext());
+    return Retrying.run(
+        getOptions(),
+        retryAlgorithmManager.getFor(req),
+        () -> storageClient.restoreObjectCallable().call(req, merge),
+        resp -> {
+          BlobInfo tmp = codecs.blobInfo().decode(resp);
+          return finalOpts.clearBlobFields().decode(tmp).asBlob(this);
+        });
+  }
+
   @Override
   public Page<Bucket> list(BucketListOption... options) {
     Opts<BucketListOpt> opts = Opts.unwrap(options).prepend(defaultOpts).prepend(ALL_BUCKET_FIELDS);
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 22cac9a7..c5163ad0 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
@@ -213,6 +213,11 @@ final class HttpRetryAlgorithmManager implements Serializable {
     return retryStrategy.getIdempotentHandler();
   }
 
+  public ResultRetryAlgorithm<?> getForObjectsRestore(
+      StorageObject pb, Map<StorageRpc.Option, ?> optionsMap) {
+    return retryStrategy.getIdempotentHandler();
+  }
+
   public ResultRetryAlgorithm<?> getForObjectsUpdate(
       StorageObject pb, Map<StorageRpc.Option, ?> optionsMap) {
     return optionsMap.containsKey(StorageRpc.Option.IF_METAGENERATION_MATCH)
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/JsonConversions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/JsonConversions.java
index c5add241..7f84db77 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/JsonConversions.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/JsonConversions.java
@@ -16,6 +16,7 @@
 
 package com.google.cloud.storage;
 
+import static com.google.cloud.storage.Storage.BucketField.SOFT_DELETE_POLICY;
 import static com.google.cloud.storage.Utils.dateTimeCodec;
 import static com.google.cloud.storage.Utils.durationSecondsCodec;
 import static com.google.cloud.storage.Utils.ifNonNull;
@@ -66,6 +67,7 @@ import com.google.cloud.storage.BucketInfo.LifecycleRule.SetStorageClassLifecycl
 import com.google.cloud.storage.BucketInfo.Logging;
 import com.google.cloud.storage.BucketInfo.ObjectRetention;
 import com.google.cloud.storage.BucketInfo.PublicAccessPrevention;
+import com.google.cloud.storage.BucketInfo.SoftDeletePolicy;
 import com.google.cloud.storage.Conversions.Codec;
 import com.google.cloud.storage.Cors.Origin;
 import com.google.cloud.storage.HmacKey.HmacKeyMetadata;
@@ -119,6 +121,9 @@ final class JsonConversions {
 
   private final Codec<ObjectRetention, Bucket.ObjectRetention> objectRetentionCodec =
       Codec.of(this::objectRetentionEncode, this::objectRetentionDecode);
+
+  private final Codec<SoftDeletePolicy, Bucket.SoftDeletePolicy> softDeletePolicyCodec =
+      Codec.of(this::softDeletePolicyEncode, this::softDeletePolicyDecode);
   private final Codec<LifecycleRule, Rule> lifecycleRuleCodec =
       Codec.of(this::lifecycleRuleEncode, this::lifecycleRuleDecode);
   private final Codec<LifecycleCondition, Condition> lifecycleConditionCodec =
@@ -251,6 +256,9 @@ final class JsonConversions {
         dateTimeCodec::encode,
         to::setRetentionExpirationTime);
 
+    ifNonNull(from.getSoftDeleteTime(), dateTimeCodec::encode, to::setSoftDeleteTime);
+    ifNonNull(from.getHardDeleteTime(), dateTimeCodec::encode, to::setHardDeleteTime);
+
     // todo: clean this up once retention is enabled in grpc
     // This is a workaround so that explicitly null retention objects are only included when the
     // user set an existing policy to null, to avoid sending any retention objects to the test
@@ -332,6 +340,8 @@ final class JsonConversions {
         dateTimeCodec::decode,
         to::setRetentionExpirationTimeOffsetDateTime);
     ifNonNull(from.getRetention(), this::retentionDecode, to::setRetention);
+    ifNonNull(from.getSoftDeleteTime(), dateTimeCodec::decode, to::setSoftDeleteTime);
+    ifNonNull(from.getHardDeleteTime(), dateTimeCodec::decode, to::setHardDeleteTime);
     return to.build();
   }
 
@@ -371,6 +381,21 @@ final class JsonConversions {
     return to.build();
   }
 
+  private Bucket.SoftDeletePolicy softDeletePolicyEncode(SoftDeletePolicy from) {
+    Bucket.SoftDeletePolicy to = new Bucket.SoftDeletePolicy();
+    ifNonNull(
+        from.getRetentionDuration(), durationSecondsCodec::encode, to::setRetentionDurationSeconds);
+    return to;
+  }
+
+  private SoftDeletePolicy softDeletePolicyDecode(Bucket.SoftDeletePolicy from) {
+    SoftDeletePolicy.Builder to = SoftDeletePolicy.newBuilder();
+    ifNonNull(
+        from.getRetentionDurationSeconds(), durationSecondsCodec::decode, to::setRetentionDuration);
+    ifNonNull(from.getEffectiveTime(), dateTimeCodec::decode, to::setEffectiveTime);
+    return to.build();
+  }
+
   private Bucket bucketInfoEncode(BucketInfo from) {
     Bucket to = new Bucket();
     ifNonNull(from.getProject(), projectNameCodec::encode, p -> to.set(PROJECT_ID_FIELD_NAME, p));
@@ -441,6 +466,11 @@ final class JsonConversions {
         this::customPlacementConfigEncode,
         to::setCustomPlacementConfig);
     ifNonNull(from.getObjectRetention(), this::objectRetentionEncode, to::setObjectRetention);
+    ifNonNull(from.getSoftDeletePolicy(), this::softDeletePolicyEncode, to::setSoftDeletePolicy);
+    if (from.getSoftDeletePolicy() == null
+        && from.getModifiedFields().contains(SOFT_DELETE_POLICY)) {
+      to.setSoftDeletePolicy(Data.nullOf(Bucket.SoftDeletePolicy.class));
+    }
     ifNonNull(
         from.getHierarchicalNamespace(),
         this::hierarchicalNamespaceEncode,
@@ -500,6 +530,7 @@ final class JsonConversions {
         this::hierarchicalNamespaceDecode,
         to::setHierarchicalNamespace);
     ifNonNull(from.getObjectRetention(), this::objectRetentionDecode, to::setObjectRetention);
+    ifNonNull(from.getSoftDeletePolicy(), this::softDeletePolicyDecode, to::setSoftDeletePolicy);
     return to.build();
   }
 
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 1d0ae834..0f6b002d 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
@@ -163,7 +163,10 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
     @TransportCompatibility({Transport.HTTP, Transport.GRPC})
     HIERARCHICAL_NAMESPACE("hierarchicalNamespace", "hierarchical_namespace"),
     @TransportCompatibility({Transport.HTTP})
-    OBJECT_RETENTION("objectRetention");
+    OBJECT_RETENTION("objectRetention"),
+
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+    SOFT_DELETE_POLICY("softDeletePolicy", "soft_delete_policy");
 
     static final List<BucketField> REQUIRED_FIELDS = ImmutableList.of(NAME);
 
@@ -263,7 +266,13 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
     @TransportCompatibility({Transport.HTTP, Transport.GRPC})
     CUSTOMER_ENCRYPTION("customerEncryption", "customer_encryption"),
     @TransportCompatibility({Transport.HTTP})
-    RETENTION("retention");
+    RETENTION("retention"),
+
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+    SOFT_DELETE_TIME("softDeleteTime", "soft_delete_time"),
+
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+    HARD_DELETE_TIME("hardDeleteTime", "hard_delete_time");
 
     static final List<NamedField> REQUIRED_FIELDS = ImmutableList.of(BUCKET, NAME);
 
@@ -1572,6 +1581,16 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
       return new BlobGetOption(UnifiedOpts.returnRawInputStream(shouldReturnRawInputStream));
     }
 
+    /**
+     * Returns an option for whether the request should return a soft-deleted object. If an object
+     * has been soft-deleted (Deleted while a Soft Delete Policy) is in place, this must be true or
+     * the request will return null.
+     */
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+    public static BlobGetOption softDeleted(boolean softDeleted) {
+      return new BlobGetOption(UnifiedOpts.softDeleted(softDeleted));
+    }
+
     /**
      * Deduplicate any options which are the same parameter. The value which comes last in {@code
      * os} will be the value included in the return.
@@ -1607,6 +1626,61 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
     }
   }
 
+  /** Class for specifying blob restore options * */
+  class BlobRestoreOption extends Option<ObjectSourceOpt> {
+
+    private static final long serialVersionUID = 1922118465380110958L;
+
+    BlobRestoreOption(ObjectSourceOpt opt) {
+      super(opt);
+    }
+
+    /**
+     * Returns an option for blob's data generation match. If this option is used the request will
+     * fail if generation does not match.
+     */
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+    public static BlobRestoreOption generationMatch(long generation) {
+      return new BlobRestoreOption(UnifiedOpts.generationMatch(generation));
+    }
+
+    /**
+     * Returns an option for blob's data generation mismatch. If this option is used the request
+     * will fail if blob's generation matches the provided value.
+     */
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+    public static BlobRestoreOption generationNotMatch(long generation) {
+      return new BlobRestoreOption(UnifiedOpts.generationNotMatch(generation));
+    }
+
+    /**
+     * Returns an option for blob's metageneration match. If this option is used the request will
+     * fail if blob's metageneration does not match the provided value.
+     */
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+    public static BlobRestoreOption metagenerationMatch(long generation) {
+      return new BlobRestoreOption(UnifiedOpts.metagenerationMatch(generation));
+    }
+
+    /**
+     * Returns an option for blob's metageneration mismatch. If this option is used the request will
+     * fail if blob's metageneration matches the provided value.
+     */
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+    public static BlobRestoreOption metagenerationNotMatch(long generation) {
+      return new BlobRestoreOption(UnifiedOpts.metagenerationNotMatch(generation));
+    }
+
+    /**
+     * Returns an option for whether the restored object should copy the access controls of the
+     * source object.
+     */
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+    public static BlobRestoreOption copySourceAcl(boolean copySourceAcl) {
+      return new BlobRestoreOption(UnifiedOpts.copySourceAcl(copySourceAcl));
+    }
+  }
+
   /** Class for specifying bucket list options. */
   class BucketListOption extends Option<BucketListOpt> {
 
@@ -1838,6 +1912,12 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
       return new BlobListOption(UnifiedOpts.fields(set));
     }
 
+    /** Returns an option for whether the list result should include soft-deleted objects. */
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+    public static BlobListOption softDeleted(boolean softDeleted) {
+      return new BlobListOption(UnifiedOpts.softDeleted(softDeleted));
+    }
+
     /**
      * Deduplicate any options which are the same parameter. The value which comes last in {@code
      * os} will be the value included in the return.
@@ -3043,6 +3123,23 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
   @TransportCompatibility({Transport.HTTP, Transport.GRPC})
   Blob get(BlobId blob);
 
+  /**
+   * Restores a soft-deleted object to full object status and returns the object. Note that you must
+   * specify a generation to use this method.
+   *
+   * <p>Example of restoring an object.
+   *
+   * <pre>{@code
+   * String bucketName = "my-unique-bucket";
+   * String blobName = "my-blob-name";
+   * long generation = 42;
+   * BlobId blobId = BlobId.of(bucketName, blobName, gen);
+   * Blob blob = storage.restore(blobId);
+   * }</pre>
+   */
+  @TransportCompatibility({Transport.HTTP, Transport.GRPC})
+  Blob restore(BlobId blob, BlobRestoreOption... options);
+
   /**
    * Lists the project's buckets.
    *
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 9af54fee..a5df68b8 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
@@ -341,6 +341,24 @@ final class StorageImpl extends BaseService<StorageOptions> implements Storage {
     return get(blob, new BlobGetOption[0]);
   }
 
+  @Override
+  public Blob restore(BlobId blob, BlobRestoreOption... options) {
+    ImmutableMap<StorageRpc.Option, ?> optionsMap =
+        Opts.unwrap(options).resolveFrom(blob).getRpcOptions();
+
+    StorageObject obj = codecs.blobId().encode(blob);
+
+    ResultRetryAlgorithm<?> algorithm = retryAlgorithmManager.getForObjectsRestore(obj, optionsMap);
+
+    return run(
+        algorithm,
+        () -> storageRpc.restore(obj, optionsMap),
+        (x) -> {
+          BlobInfo info = Conversions.json().blobInfo().decode(x);
+          return info.asBlob(this);
+        });
+  }
+
   private static class BucketPageFetcher implements NextPageFetcher<Bucket> {
 
     private static final long serialVersionUID = 8534413447247364038L;
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 3159cbeb..06909620 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
@@ -53,6 +53,7 @@ import com.google.storage.v2.ListHmacKeysRequest;
 import com.google.storage.v2.ListObjectsRequest;
 import com.google.storage.v2.LockBucketRetentionPolicyRequest;
 import com.google.storage.v2.ReadObjectRequest;
+import com.google.storage.v2.RestoreObjectRequest;
 import com.google.storage.v2.RewriteObjectRequest;
 import com.google.storage.v2.UpdateBucketRequest;
 import com.google.storage.v2.UpdateHmacKeyRequest;
@@ -157,6 +158,10 @@ final class UnifiedOpts {
     default Mapper<RewriteObjectRequest.Builder> rewriteObject() {
       return Mapper.identity();
     }
+
+    default Mapper<RestoreObjectRequest.Builder> restoreObject() {
+      return Mapper.identity();
+    }
   }
 
   /**
@@ -482,6 +487,14 @@ final class UnifiedOpts {
     return new Projection(projection);
   }
 
+  static SoftDeleted softDeleted(boolean softDeleted) {
+    return new SoftDeleted(softDeleted);
+  }
+
+  static CopySourceAcl copySourceAcl(boolean copySourceAcl) {
+    return new CopySourceAcl(copySourceAcl);
+  }
+
   static RequestedPolicyVersion requestedPolicyVersion(long l) {
     return new RequestedPolicyVersion(l);
   }
@@ -667,6 +680,40 @@ final class UnifiedOpts {
     }
   }
 
+  static final class SoftDeleted extends RpcOptVal<Boolean>
+      implements ObjectListOpt, ObjectSourceOpt {
+
+    private static final long serialVersionUID = -8526951678111463350L;
+
+    private SoftDeleted(boolean val) {
+      super(StorageRpc.Option.SOFT_DELETED, val);
+    }
+
+    @Override
+    public Mapper<ListObjectsRequest.Builder> listObjects() {
+      return b -> b.setSoftDeleted(val);
+    }
+
+    @Override
+    public Mapper<GetObjectRequest.Builder> getObject() {
+      return b -> b.setSoftDeleted(val);
+    }
+  }
+
+  static final class CopySourceAcl extends RpcOptVal<Boolean> implements ObjectSourceOpt {
+
+    private static final long serialVersionUID = 2033755749149128119L;
+
+    private CopySourceAcl(boolean val) {
+      super(StorageRpc.Option.COPY_SOURCE_ACL, val);
+    }
+
+    @Override
+    public Mapper<RestoreObjectRequest.Builder> restoreObject() {
+      return b -> b.setCopySourceAcl(val);
+    }
+  }
+
   static final class DisableGzipContent extends RpcOptVal<@NonNull Boolean>
       implements ObjectTargetOpt {
     private static final long serialVersionUID = 7445066765944965549L;
@@ -1008,6 +1055,11 @@ final class UnifiedOpts {
       return b -> b.setIfGenerationMatch(val);
     }
 
+    @Override
+    public Mapper<RestoreObjectRequest.Builder> restoreObject() {
+      return b -> b.setIfGenerationMatch(val);
+    }
+
     @Override
     public Mapper<UpdateObjectRequest.Builder> updateObject() {
       return b -> b.setIfGenerationMatch(val);
@@ -1064,6 +1116,11 @@ final class UnifiedOpts {
       return b -> b.setIfGenerationNotMatch(val);
     }
 
+    @Override
+    public Mapper<RestoreObjectRequest.Builder> restoreObject() {
+      return b -> b.setIfGenerationNotMatch(val);
+    }
+
     @Override
     public Mapper<UpdateObjectRequest.Builder> updateObject() {
       return b -> b.setIfGenerationNotMatch(val);
@@ -1205,6 +1262,11 @@ final class UnifiedOpts {
       return b -> b.setIfMetagenerationMatch(val);
     }
 
+    @Override
+    public Mapper<RestoreObjectRequest.Builder> restoreObject() {
+      return b -> b.setIfMetagenerationMatch(val);
+    }
+
     @Override
     public Mapper<UpdateObjectRequest.Builder> updateObject() {
       return b -> b.setIfMetagenerationMatch(val);
@@ -1285,6 +1347,11 @@ final class UnifiedOpts {
       return b -> b.setIfMetagenerationNotMatch(val);
     }
 
+    @Override
+    public Mapper<RestoreObjectRequest.Builder> restoreObject() {
+      return b -> b.setIfMetagenerationNotMatch(val);
+    }
+
     @Override
     public Mapper<UpdateObjectRequest.Builder> updateObject() {
       return b -> b.setIfMetagenerationNotMatch(val);
@@ -2297,6 +2364,10 @@ final class UnifiedOpts {
       return fuseMappers(ObjectSourceOpt.class, ObjectSourceOpt::getObject);
     }
 
+    Mapper<RestoreObjectRequest.Builder> restoreObjectRequest() {
+      return fuseMappers(ObjectSourceOpt.class, ObjectSourceOpt::restoreObject);
+    }
+
     Mapper<ReadObjectRequest.Builder> readObjectRequest() {
       return fuseMappers(ObjectSourceOpt.class, ObjectSourceOpt::readObject);
     }
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 3ca2eabe..8a973427 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
@@ -459,6 +459,7 @@ public class HttpStorageRpc implements StorageRpc {
               .setPageToken(Option.PAGE_TOKEN.getString(options))
               .setFields(Option.FIELDS.getString(options))
               .setUserProject(Option.USER_PROJECT.getString(options))
+              .setSoftDeleted(Option.SOFT_DELETED.getBoolean(options))
               .setIncludeFoldersAsPrefixes(Option.INCLUDE_FOLDERS_AS_PREFIXES.getBoolean(options))
               .execute();
       Iterable<StorageObject> storageObjects =
@@ -541,7 +542,8 @@ public class HttpStorageRpc implements StorageRpc {
         .setIfGenerationMatch(Option.IF_GENERATION_MATCH.getLong(options))
         .setIfGenerationNotMatch(Option.IF_GENERATION_NOT_MATCH.getLong(options))
         .setFields(Option.FIELDS.getString(options))
-        .setUserProject(Option.USER_PROJECT.getString(options));
+        .setUserProject(Option.USER_PROJECT.getString(options))
+        .setSoftDeleted(Option.SOFT_DELETED.getBoolean(options));
   }
 
   @Override
@@ -563,6 +565,36 @@ public class HttpStorageRpc implements StorageRpc {
     }
   }
 
+  @Override
+  public StorageObject restore(StorageObject object, Map<Option, ?> options) {
+    Span span = startSpan(HttpStorageRpcSpans.SPAN_NAME_RESTORE_OBJECT);
+    Scope scope = tracer.withSpan(span);
+    try {
+      Storage.Objects.Restore restore =
+          storage.objects().restore(object.getBucket(), object.getName(), object.getGeneration());
+      return restore
+          .setProjection(DEFAULT_PROJECTION)
+          .setIfMetagenerationMatch(Option.IF_METAGENERATION_MATCH.getLong(options))
+          .setIfMetagenerationNotMatch(Option.IF_METAGENERATION_NOT_MATCH.getLong(options))
+          .setIfGenerationMatch(Option.IF_GENERATION_MATCH.getLong(options))
+          .setIfGenerationNotMatch(Option.IF_GENERATION_NOT_MATCH.getLong(options))
+          .setCopySourceAcl(Option.COPY_SOURCE_ACL.getBoolean(options))
+          .setUserProject(Option.USER_PROJECT.getString(options))
+          .setFields(Option.FIELDS.getString(options))
+          .execute();
+    } catch (IOException ex) {
+      span.setStatus(Status.UNKNOWN.withDescription(ex.getMessage()));
+      StorageException serviceException = translate(ex);
+      if (serviceException.getCode() == HTTP_NOT_FOUND) {
+        return null;
+      }
+      throw serviceException;
+    } finally {
+      scope.close();
+      span.end(HttpStorageRpcSpans.END_SPAN_OPTIONS);
+    }
+  }
+
   @Override
   public Bucket patch(Bucket bucket, Map<Option, ?> options) {
     Span span = startSpan(HttpStorageRpcSpans.SPAN_NAME_PATCH_BUCKET);
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpcSpans.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpcSpans.java
index 3f3d27d9..dc4b0533 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpcSpans.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpcSpans.java
@@ -30,6 +30,7 @@ class HttpStorageRpcSpans {
   static final String SPAN_NAME_LIST_OBJECTS = getTraceSpanName("list(String,Map)");
   static final String SPAN_NAME_GET_BUCKET = getTraceSpanName("get(Bucket,Map)");
   static final String SPAN_NAME_GET_OBJECT = getTraceSpanName("get(StorageObject,Map)");
+  static final String SPAN_NAME_RESTORE_OBJECT = getTraceSpanName("restore(StorageObject, Map)");
   static final String SPAN_NAME_PATCH_BUCKET = getTraceSpanName("patch(Bucket,Map)");
   static final String SPAN_NAME_PATCH_OBJECT = getTraceSpanName("patch(StorageObject,Map)");
   static final String SPAN_NAME_DELETE_BUCKET = getTraceSpanName("delete(Bucket,Map)");
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 3b40f6a2..d4e0abbf 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
@@ -74,8 +74,10 @@ public interface StorageRpc extends ServiceRpc {
     ENABLE_OBJECT_RETENTION("enableObjectRetention"),
     RETURN_RAW_INPUT_STREAM("returnRawInputStream"),
     OVERRIDE_UNLOCKED_RETENTION("overrideUnlockedRetention"),
+    SOFT_DELETED("softDeleted"),
+    COPY_SOURCE_ACL("copySourceAcl"),
+    GENERATION("generation"),
     INCLUDE_FOLDERS_AS_PREFIXES("includeFoldersAsPrefixes");
-    ;
 
     private final String value;
 
@@ -243,6 +245,13 @@ public interface StorageRpc extends ServiceRpc {
    */
   StorageObject get(StorageObject object, Map<Option, ?> options);
 
+  /**
+   * If an object has been soft-deleted, restores it and returns the restored object.j
+   *
+   * @throws StorageException upon failure
+   */
+  StorageObject restore(StorageObject object, Map<Option, ?> options);
+
   /**
    * Updates bucket information.
    *


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 97104634..6686cb92 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
@@ -71,6 +71,11 @@ public class StorageRpcTestBase implements StorageRpc {
     throw new UnsupportedOperationException("Not implemented yet");
   }
 
+  @Override
+  public StorageObject restore(StorageObject object, Map<Option, ?> options) {
+    throw new UnsupportedOperationException("Not implemented yet");
+  }
+
   @Override
   public Bucket patch(Bucket bucket, Map<Option, ?> options) {
     throw new UnsupportedOperationException("Not implemented yet");
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITBlobReadMaskTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITBlobReadMaskTest.java
index c2594a48..bf8c4825 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITBlobReadMaskTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITBlobReadMaskTest.java
@@ -201,7 +201,9 @@ public final class ITBlobReadMaskTest {
               new Args<>(BlobField.UPDATED, LazyAssertion.equal()),
               new Args<>(
                   BlobField.RETENTION,
-                  LazyAssertion.skip("TODO: jesse fill in buganizer bug here")));
+                  LazyAssertion.skip("TODO: jesse fill in buganizer bug here")),
+              new Args<>(BlobField.SOFT_DELETE_TIME, LazyAssertion.equal()),
+              new Args<>(BlobField.HARD_DELETE_TIME, LazyAssertion.equal()));
       List<String> argsDefined =
           args.stream().map(Args::getField).map(Enum::name).sorted().collect(Collectors.toList());
 
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITBucketReadMaskTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITBucketReadMaskTest.java
index 9c977603..c4c3c405 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITBucketReadMaskTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITBucketReadMaskTest.java
@@ -40,6 +40,7 @@ import com.google.cloud.storage.it.runner.annotations.Parameterized.ParametersPr
 import com.google.cloud.storage.it.runner.annotations.SingleBackend;
 import com.google.cloud.storage.it.runner.annotations.StorageFixture;
 import com.google.common.collect.ImmutableList;
+import java.time.temporal.ChronoUnit;
 import java.util.Arrays;
 import java.util.List;
 import java.util.stream.Collectors;
@@ -133,8 +134,27 @@ public final class ITBucketReadMaskTest {
               new Args<>(BucketField.TIME_CREATED, LazyAssertion.equal()),
               new Args<>(BucketField.UPDATED, LazyAssertion.equal()),
               new Args<>(BucketField.VERSIONING, LazyAssertion.equal()),
-              new Args<>(BucketField.HIERARCHICAL_NAMESPACE, LazyAssertion.equal()),
-              new Args<>(BucketField.WEBSITE, LazyAssertion.equal()));
+              new Args<>(BucketField.WEBSITE, LazyAssertion.equal()),
+              new Args<>(
+                  BucketField.SOFT_DELETE_POLICY,
+                  (jsonT, grpcT) -> {
+                    assertThat(
+                        jsonT
+                            .getSoftDeletePolicy()
+                            .getRetentionDuration()
+                            .equals(grpcT.getSoftDeletePolicy().getRetentionDuration()));
+                    assertThat(
+                        jsonT
+                            .getSoftDeletePolicy()
+                            .getEffectiveTime()
+                            .truncatedTo(ChronoUnit.SECONDS)
+                            .equals(
+                                grpcT
+                                    .getSoftDeletePolicy()
+                                    .getEffectiveTime()
+                                    .truncatedTo(ChronoUnit.SECONDS)));
+                  }),
+              new Args<>(BucketField.HIERARCHICAL_NAMESPACE, LazyAssertion.equal()));
 
       List<String> argsDefined =
           args.stream().map(Args::getField).map(Enum::name).sorted().collect(Collectors.toList());
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITBucketTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITBucketTest.java
index 5b759be6..97ab8ad3 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITBucketTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITBucketTest.java
@@ -554,6 +554,67 @@ public class ITBucketTest {
     }
   }
 
+  @Test
+  public void testSoftDeletePolicy() {
+    String bucketName = generator.randomBucketName();
+    BucketInfo bucketInfo =
+        BucketInfo.newBuilder(bucketName)
+            .setSoftDeletePolicy(
+                BucketInfo.SoftDeletePolicy.newBuilder()
+                    .setRetentionDuration(Duration.ofDays(10))
+                    .build())
+            .build();
+    try {
+      storage.create(bucketInfo);
+
+      Bucket remoteBucket = storage.get(bucketName);
+      assertEquals(Duration.ofDays(10), remoteBucket.getSoftDeletePolicy().getRetentionDuration());
+      assertNotNull(remoteBucket.getSoftDeletePolicy().getEffectiveTime());
+
+      String softDelBlobName = "softdelblob";
+      remoteBucket.create(softDelBlobName, BLOB_BYTE_CONTENT);
+
+      Blob blob = remoteBucket.get(softDelBlobName);
+      long gen = blob.getGeneration();
+
+      assertNull(blob.getSoftDeleteTime());
+      assertNull(blob.getHardDeleteTime());
+
+      blob.delete();
+
+      assertNull(remoteBucket.get(softDelBlobName));
+
+      ImmutableList<Blob> softDeletedBlobs =
+          ImmutableList.copyOf(
+              remoteBucket.list(Storage.BlobListOption.softDeleted(true)).iterateAll());
+      assertThat(softDeletedBlobs.size() > 0);
+
+      Blob softDeletedBlob =
+          remoteBucket.get(softDelBlobName, gen, Storage.BlobGetOption.softDeleted(true));
+
+      assertNotNull(softDeletedBlob);
+      assertNotNull(softDeletedBlob.getSoftDeleteTime());
+      assertNotNull(softDeletedBlob.getHardDeleteTime());
+
+      assertNotNull(storage.restore(softDeletedBlob.getBlobId()));
+
+      remoteBucket
+          .toBuilder()
+          .setSoftDeletePolicy(
+              BucketInfo.SoftDeletePolicy.newBuilder()
+                  .setRetentionDuration(Duration.ofDays(20))
+                  .build())
+          .build()
+          .update();
+
+      assertEquals(
+          Duration.ofDays(20),
+          storage.get(bucketName).getSoftDeletePolicy().getRetentionDuration());
+    } finally {
+      BucketCleaner.doCleanup(bucketName, storage);
+    }
+  }
+
   @Test
   public void createBucketWithHierarchicalNamespace() {
     String bucketName = generator.randomBucketName();
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITOptionRegressionTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITOptionRegressionTest.java
index 7627bf3a..c7032ea6 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITOptionRegressionTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITOptionRegressionTest.java
@@ -326,6 +326,8 @@ public final class ITOptionRegressionTest {
             "timeCreated",
             "updated",
             "versioning",
+            "website",
+            "softDeletePolicy",
             "hierarchicalNamespace",
             "website");
     s.get(
@@ -739,7 +741,9 @@ public final class ITOptionRegressionTest {
             "timeDeleted",
             "timeStorageClassUpdated",
             "updated",
-            "retention");
+            "retention",
+            "softDeleteTime",
+            "hardDeleteTime");
     s.get(o.getBlobId(), BlobGetOption.fields(BlobField.values()));
     requestAuditing.assertQueryParam("fields", expected, splitOnCommaToSet());
   }
@@ -817,6 +821,8 @@ public final class ITOptionRegressionTest {
             "items/timeCreated",
             "items/updated",
             "items/versioning",
+            "items/website",
+            "items/softDeletePolicy",
             "items/hierarchicalNamespace",
             "items/website");
     s.list(BucketListOption.fields(TestUtils.filterOutHttpOnlyBucketFields(BucketField.values())));
@@ -915,7 +921,9 @@ public final class ITOptionRegressionTest {
             "items/timeDeleted",
             "items/timeStorageClassUpdated",
             "items/updated",
-            "items/retention");
+            "items/retention",
+            "items/softDeleteTime",
+            "items/hardDeleteTime");
     s.list(b.getName(), BlobListOption.fields(BlobField.values()));
     requestAuditing.assertQueryParam("fields", expected, splitOnCommaToSet());
   }
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 d264e5a6..9e5e9691 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
@@ -139,6 +139,11 @@ abstract class AbstractStorageProxy implements Storage {
     return delegate.get(blob);
   }
 
+  @Override
+  public Blob restore(BlobId blob, BlobRestoreOption... options) {
+    return delegate.restore(blob, options);
+  }
+
   @Override
   public Page<Bucket> list(BucketListOption... options) {
     return delegate.list(options);