Repository: googleapis/java-storage (java)

Issue:
storage: support startOffset and endOffset query param during object listing
 Currently we don't have support to set the `startOffset `and `endOffset ` during the object listing. 

Similar case found in go as well see https://github.com/googleapis/google-cloud-go/issues/2556 also check the[ JSON API](https://cloud.google.com/storage/docs/json_api/v1/objects/list#http-request) for more info.

~/cc: @frankyn 



Pull request description:
feat: add support of startOffset and endOffset
Fixes #424 


Patch:
diff --git a/google-cloud-storage/pom.xml b/google-cloud-storage/pom.xml
index 196a6653..ffacd705 100644
--- a/google-cloud-storage/pom.xml
+++ b/google-cloud-storage/pom.xml
@@ -2,7 +2,7 @@
 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>
   <artifactId>google-cloud-storage</artifactId>
-  <version>1.111.2</version><!-- {x-version-update:google-cloud-storage:current} -->
+  <version>1.111.3-SNAPSHOT</version><!-- {x-version-update:google-cloud-storage:current} -->
   <packaging>jar</packaging>
   <name>Google Cloud Storage</name>
   <url>https://github.com/googleapis/java-storage</url>
@@ -12,7 +12,7 @@
   <parent>
     <groupId>com.google.cloud</groupId>
     <artifactId>google-cloud-storage-parent</artifactId>
-    <version>1.111.2</version><!-- {x-version-update:google-cloud-storage:current} -->
+    <version>1.111.3-SNAPSHOT</version><!-- {x-version-update:google-cloud-storage:current} -->
   </parent>
   <properties>
     <site.installationModule>google-cloud-storage</site.installationModule>
@@ -33,7 +33,7 @@
     <dependency>
       <groupId>com.google.api-client</groupId>
       <artifactId>google-api-client</artifactId>
-      <version>1.30.9</version>
+      <version>1.30.10</version>
     </dependency>
     <dependency>
       <groupId>com.google.apis</groupId>
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/PostPolicyV4.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/PostPolicyV4.java
index df2936dc..cc9b413a 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/PostPolicyV4.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/PostPolicyV4.java
@@ -31,8 +31,8 @@ import java.util.Set;
  * @see <a href="https://cloud.google.com/storage/docs/xml-api/post-object">POST Object</a>
  */
 public final class PostPolicyV4 {
-  private String url;
-  private Map<String, String> fields;
+  private final String url;
+  private final Map<String, String> fields;
 
   private PostPolicyV4(String url, Map<String, String> fields) {
     this.url = url;
@@ -58,7 +58,7 @@ public final class PostPolicyV4 {
    *     Object Form fields</a>
    */
   public static final class PostFieldsV4 {
-    private Map<String, String> fieldsMap;
+    private final Map<String, String> fieldsMap;
 
     private PostFieldsV4(Builder builder) {
       this.fieldsMap = builder.fieldsMap;
@@ -81,10 +81,11 @@ public final class PostPolicyV4 {
     }
 
     public static class Builder {
-      private Map<String, String> fieldsMap;
+      private static final String CUSTOM_FIELD_PREFIX = "x-goog-meta-";
+      private final Map<String, String> fieldsMap;
 
       private Builder() {
-        fieldsMap = new HashMap<>();
+        this.fieldsMap = new HashMap<>();
       }
 
       public PostFieldsV4 build() {
@@ -121,7 +122,13 @@ public final class PostPolicyV4 {
         return this;
       }
 
+      /** @deprecated use {@link #setExpires(String)} */
+      @Deprecated
       public Builder Expires(String expires) {
+        return setExpires(expires);
+      }
+
+      public Builder setExpires(String expires) {
         fieldsMap.put("expires", expires);
         return this;
       }
@@ -136,8 +143,17 @@ public final class PostPolicyV4 {
         return this;
       }
 
+      /** @deprecated use {@link #setCustomMetadataField(String, String)} */
+      @Deprecated
       public Builder AddCustomMetadataField(String field, String value) {
-        fieldsMap.put("x-goog-meta-" + field, value);
+        return setCustomMetadataField(field, value);
+      }
+
+      public Builder setCustomMetadataField(String field, String value) {
+        if (!field.startsWith(CUSTOM_FIELD_PREFIX)) {
+          field = CUSTOM_FIELD_PREFIX + value;
+        }
+        fieldsMap.put(field, value);
         return this;
       }
     }
@@ -270,8 +286,8 @@ public final class PostPolicyV4 {
    *     Policy document</a>
    */
   public static final class PostPolicyV4Document {
-    private String expiration;
-    private PostConditionsV4 conditions;
+    private final String expiration;
+    private final PostConditionsV4 conditions;
 
     private PostPolicyV4Document(String expiration, PostConditionsV4 conditions) {
       this.expiration = expiration;
@@ -363,9 +379,9 @@ public final class PostPolicyV4 {
    *     Policy document</a>
    */
   static final class ConditionV4 {
-    ConditionV4Type type;
-    String operand1;
-    String operand2;
+    final ConditionV4Type type;
+    final String operand1;
+    final String operand2;
 
     private ConditionV4(ConditionV4Type type, String operand1, String operand2) {
       this.type = type;
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 eb15dd37..82ba1750 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
@@ -521,6 +521,15 @@ public interface Storage extends Service<StorageOptions> {
       return new BlobTargetOption(StorageRpc.Option.IF_DISABLE_GZIP_CONTENT, true);
     }
 
+    /**
+     * Returns an option for detecting content type. If this option is used, the content type is
+     * detected from the blob name if not explicitly set. This option is on the client side only, it
+     * does not appear in a RPC call.
+     */
+    public static BlobTargetOption detectContentType() {
+      return new BlobTargetOption(StorageRpc.Option.DETECT_CONTENT_TYPE, true);
+    }
+
     /**
      * Returns an option to set a customer-supplied AES256 key for server-side encryption of the
      * blob.
@@ -593,6 +602,7 @@ public interface Storage extends Service<StorageOptions> {
       CUSTOMER_SUPPLIED_KEY,
       KMS_KEY_NAME,
       USER_PROJECT,
+      DETECT_CONTENT_TYPE,
       IF_DISABLE_GZIP_CONTENT;
 
       StorageRpc.Option toRpcOption() {
@@ -733,6 +743,15 @@ public interface Storage extends Service<StorageOptions> {
     public static BlobWriteOption disableGzipContent() {
       return new BlobWriteOption(Option.IF_DISABLE_GZIP_CONTENT, true);
     }
+
+    /**
+     * Returns an option for detecting content type. If this option is used, the content type is
+     * detected from the blob name if not explicitly set. This option is on the client side only, it
+     * does not appear in a RPC call.
+     */
+    public static BlobWriteOption detectContentType() {
+      return new BlobWriteOption(Option.DETECT_CONTENT_TYPE, true);
+    }
   }
 
   /** Class for specifying blob source options. */
@@ -1032,6 +1051,28 @@ public interface Storage extends Service<StorageOptions> {
       return new BlobListOption(StorageRpc.Option.DELIMITER, delimiter);
     }
 
+    /**
+     * Returns an option to set a startOffset to filter results to objects whose names are
+     * lexicographically equal to or after startOffset. If endOffset is also set, the objects listed
+     * have names between startOffset (inclusive) and endOffset (exclusive).
+     *
+     * @param startOffset startOffset to filter the results
+     */
+    public static BlobListOption startOffset(String startOffset) {
+      return new BlobListOption(StorageRpc.Option.START_OFF_SET, startOffset);
+    }
+
+    /**
+     * Returns an option to set a endOffset to filter results to objects whose names are
+     * lexicographically before endOffset. If startOffset is also set, the objects listed have names
+     * between startOffset (inclusive) and endOffset (exclusive).
+     *
+     * @param endOffset endOffset to filter the results
+     */
+    public static BlobListOption endOffset(String endOffset) {
+      return new BlobListOption(StorageRpc.Option.END_OFF_SET, endOffset);
+    }
+
     /**
      * Returns an option to define the billing user project. This option is required by buckets with
      * `requester_pays` flag enabled to assign operation costs.
@@ -1832,9 +1873,10 @@ public interface Storage extends Service<StorageOptions> {
    * Creates a new blob. Direct upload is used to upload {@code content}. For large content, {@link
    * #writer} is recommended as it uses resumable upload. MD5 and CRC32C hashes of {@code content}
    * are computed and used for validating transferred data. Accepts an optional userProject {@link
-   * BlobGetOption} option which defines the project id to assign operational costs.
+   * BlobGetOption} option which defines the project id to assign operational costs. The content
+   * type is detected from the blob name if not explicitly set.
    *
-   * <p>Example of creating a blob from a byte array.
+   * <p>Example of creating a blob from a byte array:
    *
    * <pre>{@code
    * String bucketName = "my-unique-bucket";
@@ -1857,7 +1899,7 @@ public interface Storage extends Service<StorageOptions> {
    * Accepts a userProject {@link BlobGetOption} option, which defines the project id to assign
    * operational costs.
    *
-   * <p>Example of creating a blob from a byte array.
+   * <p>Example of creating a blob from a byte array:
    *
    * <pre>{@code
    * String bucketName = "my-unique-bucket";
@@ -1876,7 +1918,7 @@ public interface Storage extends Service<StorageOptions> {
 
   /**
    * Creates a new blob. Direct upload is used to upload {@code content}. For large content, {@link
-   * #writer} is recommended as it uses resumable upload. By default any md5 and crc32c values in
+   * #writer} is recommended as it uses resumable upload. By default any MD5 and CRC32C values in
    * the given {@code blobInfo} are ignored unless requested via the {@code
    * BlobWriteOption.md5Match} and {@code BlobWriteOption.crc32cMatch} options. The given input
    * stream is closed upon success.
@@ -2603,11 +2645,11 @@ public interface Storage extends Service<StorageOptions> {
   ReadChannel reader(BlobId blob, BlobSourceOption... options);
 
   /**
-   * Creates a blob and return a channel for writing its content. By default any md5 and crc32c
+   * Creates a blob and returns a channel for writing its content. By default any MD5 and CRC32C
    * values in the given {@code blobInfo} are ignored unless requested via the {@code
    * BlobWriteOption.md5Match} and {@code BlobWriteOption.crc32cMatch} options.
    *
-   * <p>Example of writing a blob's content through a writer.
+   * <p>Example of writing a blob's content through a writer:
    *
    * <pre>{@code
    * String bucketName = "my-unique-bucket";
@@ -3517,7 +3559,7 @@ public interface Storage extends Service<StorageOptions> {
    * String bucketName = "my-unique-bucket";
    * List<Boolean> response =
    *     storage.testIamPermissions(
-   *         bucket,
+   *         bucketName,
    *         ImmutableList.of("storage.buckets.get", "storage.buckets.getIamPolicy"));
    * for (boolean hasPermission : response) {
    *   // Do something with permission test response
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 519a9e9f..d7b7baa0 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
@@ -77,9 +77,12 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.math.BigInteger;
+import java.net.FileNameMap;
+import java.net.URLConnection;
 import java.util.ArrayList;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 
 public class HttpStorageRpc implements StorageRpc {
@@ -98,6 +101,7 @@ public class HttpStorageRpc implements StorageRpc {
   private final HttpRequestInitializer batchRequestInitializer;
 
   private static final long MEGABYTE = 1024L * 1024L;
+  private static final FileNameMap FILE_NAME_MAP = URLConnection.getFileNameMap();
 
   public HttpStorageRpc(StorageOptions options) {
     HttpTransportOptions transportOptions = (HttpTransportOptions) options.getTransportOptions();
@@ -286,7 +290,7 @@ public class HttpStorageRpc implements StorageRpc {
               .insert(
                   storageObject.getBucket(),
                   storageObject,
-                  new InputStreamContent(storageObject.getContentType(), content));
+                  new InputStreamContent(detectContentType(storageObject, options), content));
       insert.getMediaHttpUploader().setDirectUploadEnabled(true);
       Boolean disableGzipContent = Option.IF_DISABLE_GZIP_CONTENT.getBoolean(options);
       if (disableGzipContent != null) {
@@ -350,6 +354,8 @@ public class HttpStorageRpc implements StorageRpc {
               .setProjection(DEFAULT_PROJECTION)
               .setVersions(Option.VERSIONS.getBoolean(options))
               .setDelimiter(Option.DELIMITER.getString(options))
+              .setStartOffset(Option.START_OFF_SET.getString(options))
+              .setEndOffset(Option.END_OFF_SET.getString(options))
               .setPrefix(Option.PREFIX.getString(options))
               .setMaxResults(Option.MAX_RESULTS.getLong(options))
               .setPageToken(Option.PAGE_TOKEN.getString(options))
@@ -372,6 +378,19 @@ public class HttpStorageRpc implements StorageRpc {
     }
   }
 
+  private static String detectContentType(StorageObject object, Map<Option, ?> options) {
+    String contentType = object.getContentType();
+    if (contentType != null) {
+      return contentType;
+    }
+
+    if (Boolean.TRUE == Option.DETECT_CONTENT_TYPE.get(options)) {
+      contentType = FILE_NAME_MAP.getContentTypeFor(object.getName().toLowerCase(Locale.ENGLISH));
+    }
+
+    return firstNonNull(contentType, "application/octet-stream");
+  }
+
   private static Function<String, StorageObject> objectFromPrefix(final String bucket) {
     return new Function<String, StorageObject>() {
       @Override
@@ -834,9 +853,7 @@ public class HttpStorageRpc implements StorageRpc {
       HttpRequest httpRequest =
           requestFactory.buildPostRequest(url, new JsonHttpContent(jsonFactory, object));
       HttpHeaders requestHeaders = httpRequest.getHeaders();
-      requestHeaders.set(
-          "X-Upload-Content-Type",
-          firstNonNull(object.getContentType(), "application/octet-stream"));
+      requestHeaders.set("X-Upload-Content-Type", detectContentType(object, options));
       String key = Option.CUSTOMER_SUPPLIED_KEY.getString(options);
       if (key != null) {
         BaseEncoding base64 = BaseEncoding.base64();
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 7ae9c8ec..2088c15b 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
@@ -58,6 +58,8 @@ public interface StorageRpc extends ServiceRpc {
     MAX_RESULTS("maxResults"),
     PAGE_TOKEN("pageToken"),
     DELIMITER("delimiter"),
+    START_OFF_SET("startOffset"),
+    END_OFF_SET("endOffset"),
     VERSIONS("versions"),
     FIELDS("fields"),
     CUSTOMER_SUPPLIED_KEY("customerSuppliedKey"),
@@ -65,7 +67,8 @@ public interface StorageRpc extends ServiceRpc {
     KMS_KEY_NAME("kmsKeyName"),
     SERVICE_ACCOUNT_EMAIL("serviceAccount"),
     SHOW_DELETED_KEYS("showDeletedKeys"),
-    REQUESTED_POLICY_VERSION("optionsRequestedPolicyVersion");
+    REQUESTED_POLICY_VERSION("optionsRequestedPolicyVersion"),
+    DETECT_CONTENT_TYPE("detectContentType");
 
     private final String value;
 
diff --git a/pom.xml b/pom.xml
index 40d8e296..256dc915 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
   <groupId>com.google.cloud</groupId>
   <artifactId>google-cloud-storage-parent</artifactId>
   <packaging>pom</packaging>
-  <version>1.111.2</version><!-- {x-version-update:google-cloud-storage:current} -->
+  <version>1.111.3-SNAPSHOT</version><!-- {x-version-update:google-cloud-storage:current} -->
   <name>Storage Parent</name>
   <url>https://github.com/googleapis/java-storage</url>
   <description>
@@ -14,7 +14,7 @@
   <parent>
     <groupId>com.google.cloud</groupId>
     <artifactId>google-cloud-shared-config</artifactId>
-    <version>0.9.0</version>
+    <version>0.9.2</version>
   </parent>
 
   <developers>
@@ -78,7 +78,7 @@
       <dependency>
         <groupId>com.google.apis</groupId>
         <artifactId>google-api-services-storage</artifactId>
-        <version>v1-rev20200611-1.30.9</version>
+        <version>v1-rev20200611-1.30.10</version>
       </dependency>
       <dependency>
         <groupId>org.easymock</groupId>
diff --git a/samples/install-without-bom/pom.xml b/samples/install-without-bom/pom.xml
index cbe71fe4..4dad481d 100644
--- a/samples/install-without-bom/pom.xml
+++ b/samples/install-without-bom/pom.xml
@@ -29,7 +29,7 @@
     <dependency>
       <groupId>com.google.cloud</groupId>
       <artifactId>google-cloud-storage</artifactId>
-      <version>1.111.1</version>
+      <version>1.111.2</version>
     </dependency>
     <!-- [END storage_install_without_bom] -->
 
diff --git a/samples/snapshot/pom.xml b/samples/snapshot/pom.xml
index cad01e09..f662ee16 100644
--- a/samples/snapshot/pom.xml
+++ b/samples/snapshot/pom.xml
@@ -28,7 +28,7 @@
     <dependency>
       <groupId>com.google.cloud</groupId>
       <artifactId>google-cloud-storage</artifactId>
-      <version>1.111.1</version>
+      <version>1.111.2</version>
     </dependency>
 
     <dependency>
diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml
index 320dca50..e1a72331 100644
--- a/samples/snippets/pom.xml
+++ b/samples/snippets/pom.xml
@@ -30,7 +30,7 @@
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>libraries-bom</artifactId>
-        <version>8.0.0</version>
+        <version>8.1.0</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
diff --git a/versions.txt b/versions.txt
index 2eee4166..ef36ef92 100644
--- a/versions.txt
+++ b/versions.txt
@@ -1,4 +1,4 @@
 # Format:
 # module:released-version:current-version
 
-google-cloud-storage:1.111.2:1.111.2
\ No newline at end of file
+google-cloud-storage:1.111.2:1.111.3-SNAPSHOT
\ No newline at end of file


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java
index 6aa76a03..1b78780f 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java
@@ -665,6 +665,30 @@ public class StorageImplTest {
     assertArrayEquals(blobList.toArray(), Iterables.toArray(page.getValues(), Blob.class));
   }
 
+  @Test
+  public void testListBlobsWithOffset() {
+    String cursor = "cursor";
+    String startOffset = "startOffset";
+    String endOffset = "endOffset";
+    Map<StorageRpc.Option, ?> options =
+        ImmutableMap.of(
+            StorageRpc.Option.START_OFF_SET, startOffset, StorageRpc.Option.END_OFF_SET, endOffset);
+    ImmutableList<BlobInfo> blobInfoList = ImmutableList.of(BLOB_INFO1, BLOB_INFO2);
+    Tuple<String, Iterable<com.google.api.services.storage.model.StorageObject>> result =
+        Tuple.of(cursor, Iterables.transform(blobInfoList, BlobInfo.INFO_TO_PB_FUNCTION));
+    EasyMock.expect(storageRpcMock.list(BUCKET_NAME1, options)).andReturn(result);
+    EasyMock.replay(storageRpcMock);
+    initializeService();
+    ImmutableList<Blob> blobList = ImmutableList.of(expectedBlob1, expectedBlob2);
+    Page<Blob> page =
+        storage.list(
+            BUCKET_NAME1,
+            Storage.BlobListOption.startOffset(startOffset),
+            Storage.BlobListOption.endOffset(endOffset));
+    assertEquals(cursor, page.getNextPageToken());
+    assertArrayEquals(blobList.toArray(), Iterables.toArray(page.getValues(), Blob.class));
+  }
+
   @Test
   public void testUpdateBucket() {
     BucketInfo updatedBucketInfo = BUCKET_INFO1.toBuilder().setIndexPage("some-page").build();
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java
index ba4b3623..22b1f1d4 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java
@@ -993,6 +993,53 @@ public class ITStorageTest {
     }
   }
 
+  @Test
+  public void testListBlobsWithOffset() throws ExecutionException, InterruptedException {
+    String bucketName = RemoteStorageHelper.generateBucketName();
+    Bucket bucket =
+        storage.create(BucketInfo.newBuilder(bucketName).setVersioningEnabled(true).build());
+    try {
+      List<String> blobNames =
+          ImmutableList.of("startOffset_blob1", "startOffset_blob2", "blob3_endOffset");
+      BlobInfo blob1 =
+          BlobInfo.newBuilder(bucket, blobNames.get(0)).setContentType(CONTENT_TYPE).build();
+      BlobInfo blob2 =
+          BlobInfo.newBuilder(bucket, blobNames.get(1)).setContentType(CONTENT_TYPE).build();
+      BlobInfo blob3 =
+          BlobInfo.newBuilder(bucket, blobNames.get(2)).setContentType(CONTENT_TYPE).build();
+
+      Blob remoteBlob1 = storage.create(blob1);
+      Blob remoteBlob2 = storage.create(blob2);
+      Blob remoteBlob3 = storage.create(blob3);
+      assertNotNull(remoteBlob1);
+      assertNotNull(remoteBlob2);
+      assertNotNull(remoteBlob3);
+
+      // Listing blobs without BlobListOptions.
+      Page<Blob> page1 = storage.list(bucketName);
+      assertEquals(3, Iterators.size(page1.iterateAll().iterator()));
+
+      // Listing blobs with startOffset.
+      Page<Blob> page2 =
+          storage.list(bucketName, Storage.BlobListOption.startOffset("startOffset"));
+      assertEquals(2, Iterators.size(page2.iterateAll().iterator()));
+
+      // Listing blobs with endOffset.
+      Page<Blob> page3 = storage.list(bucketName, Storage.BlobListOption.endOffset("endOffset"));
+      assertEquals(1, Iterators.size(page3.iterateAll().iterator()));
+
+      // Listing blobs with startOffset and endOffset.
+      Page<Blob> page4 =
+          storage.list(
+              bucketName,
+              Storage.BlobListOption.startOffset("startOffset"),
+              Storage.BlobListOption.endOffset("endOffset"));
+      assertEquals(0, Iterators.size(page4.iterateAll().iterator()));
+    } finally {
+      RemoteStorageHelper.forceDelete(storage, bucketName, 5, TimeUnit.SECONDS);
+    }
+  }
+
   @Test(timeout = 5000)
   public void testListBlobsCurrentDirectory() throws InterruptedException {
     String directoryName = "test-list-blobs-current-directory/";
@@ -3363,4 +3410,61 @@ public class ITStorageTest {
     byte[] readBytes = blob.getContent(Blob.BlobSourceOption.decryptionKey(KEY));
     assertArrayEquals(BLOB_BYTE_CONTENT, readBytes);
   }
+
+  private Blob createBlob(String method, BlobInfo blobInfo, boolean detectType) throws IOException {
+    switch (method) {
+      case "create":
+        return detectType
+            ? storage.create(blobInfo, Storage.BlobTargetOption.detectContentType())
+            : storage.create(blobInfo);
+      case "createFrom":
+        InputStream inputStream = new ByteArrayInputStream(BLOB_BYTE_CONTENT);
+        return detectType
+            ? storage.createFrom(blobInfo, inputStream, Storage.BlobWriteOption.detectContentType())
+            : storage.createFrom(blobInfo, inputStream);
+      case "writer":
+        if (detectType) {
+          storage.writer(blobInfo, Storage.BlobWriteOption.detectContentType()).close();
+        } else {
+          storage.writer(blobInfo).close();
+        }
+        return storage.get(BlobId.of(blobInfo.getBucket(), blobInfo.getName()));
+      default:
+        throw new IllegalArgumentException("Unknown method " + method);
+    }
+  }
+
+  private void testAutoContentType(String method) throws IOException {
+    String[] names = {"file1.txt", "dir with spaces/Pic.Jpg", "no_extension"};
+    String[] types = {"text/plain", "image/jpeg", "application/octet-stream"};
+    for (int i = 0; i < names.length; i++) {
+      BlobId blobId = BlobId.of(BUCKET, names[i]);
+      BlobInfo blobInfo = BlobInfo.newBuilder(blobId).build();
+      Blob blob_true = createBlob(method, blobInfo, true);
+      assertEquals(types[i], blob_true.getContentType());
+
+      Blob blob_false = createBlob(method, blobInfo, false);
+      assertEquals("application/octet-stream", blob_false.getContentType());
+    }
+    String customType = "custom/type";
+    BlobId blobId = BlobId.of(BUCKET, names[0]);
+    BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setContentType(customType).build();
+    Blob blob = createBlob(method, blobInfo, true);
+    assertEquals(customType, blob.getContentType());
+  }
+
+  @Test
+  public void testAutoContentTypeCreate() throws IOException {
+    testAutoContentType("create");
+  }
+
+  @Test
+  public void testAutoContentTypeCreateFrom() throws IOException {
+    testAutoContentType("createFrom");
+  }
+
+  @Test
+  public void testAutoContentTypeWriter() throws IOException {
+    testAutoContentType("writer");
+  }
 }