Repository: googleapis/java-storage
Pull request #177 (2020-03-12 20:15:58)

feat: V4 POST policy
Adds V4 post policy. Will add documentation once implementation is approved 

Fixes: #280 #88 

codecov[bot]: # [Codecov](https://codecov.io/gh/googleapis/java-storage/pull/177?src=pr&el=h1) Report
> Merging [#177](https://codecov.io/gh/googleapis/java-storage/pull/177?src=pr&el=desc) into [master](https://codecov.io/gh/googleapis/java-storage/commit/ef5f2c6e5079debe8f7f37c3d2c501aac3dc82a6?src=pr&el=desc) will **increase** coverage by `0.1%`.
> The diff coverage is `65.65%`.

[![Impacted file tree graph](https://codecov.io/gh/googleapis/java-storage/pull/177/graphs/tree.svg?width=650&token=ssm139YOdp&height=150&src=pr)](https://codecov.io/gh/googleapis/java-storage/pull/177?src=pr&el=tree)

```diff
@@             Coverage Diff             @@
##             master     #177     +/-   ##
===========================================
+ Coverage     63.46%   63.56%   +0.1%     
- Complexity      537      545      +8     
===========================================
  Files            30       30             
  Lines          4752     4982    +230     
  Branches        427      443     +16     
===========================================
+ Hits           3016     3167    +151     
- Misses         1576     1648     +72     
- Partials        160      167      +7
```


| [Impacted Files](https://codecov.io/gh/googleapis/java-storage/pull/177?src=pr&el=tree) | Coverage Δ | Complexity Δ | |
|---|---|---|---|
| [...rc/main/java/com/google/cloud/storage/Storage.java](https://codecov.io/gh/googleapis/java-storage/pull/177/diff?src=pr&el=tree#diff-Z29vZ2xlLWNsb3VkLXN0b3JhZ2Uvc3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3N0b3JhZ2UvU3RvcmFnZS5qYXZh) | `73.34% <56.44%> (-7.24%)` | `0 <0> (ø)` | |
| [...ain/java/com/google/cloud/storage/StorageImpl.java](https://codecov.io/gh/googleapis/java-storage/pull/177/diff?src=pr&el=tree#diff-Z29vZ2xlLWNsb3VkLXN0b3JhZ2Uvc3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3N0b3JhZ2UvU3RvcmFnZUltcGwuamF2YQ==) | `79.46% <88.05%> (+0.79%)` | `128 <8> (+8)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/googleapis/java-storage/pull/177?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/googleapis/java-storage/pull/177?src=pr&el=footer). Last update [ef5f2c6...8db9aa2](https://codecov.io/gh/googleapis/java-storage/pull/177?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/google-cloud-storage/clirr-ignored-differences.xml b/google-cloud-storage/clirr-ignored-differences.xml
new file mode 100644
index 00000000..ae8d781d
--- /dev/null
+++ b/google-cloud-storage/clirr-ignored-differences.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- see http://mojo.codehaus.org/clirr-maven-plugin/examples/ignored-differences.html -->
+<differences>
+    <difference>
+        <className>com/google/cloud/storage/Storage</className>
+        <method>com.google.cloud.storage.PostPolicyV4 generateSignedPostPolicyV4(com.google.cloud.storage.BlobInfo, long, java.util.concurrent.TimeUnit, com.google.cloud.storage.PostPolicyV4$PostFieldsV4, com.google.cloud.storage.PostPolicyV4$PostConditionsV4, com.google.cloud.storage.Storage$PostPolicyV4Option[])</method>
+        <differenceType>7012</differenceType>
+    </difference>
+    <difference>
+        <className>com/google/cloud/storage/Storage</className>
+        <method>com.google.cloud.storage.PostPolicyV4 generateSignedPostPolicyV4(com.google.cloud.storage.BlobInfo, long, java.util.concurrent.TimeUnit, com.google.cloud.storage.PostPolicyV4$PostFieldsV4, com.google.cloud.storage.Storage$PostPolicyV4Option[])</method>
+        <differenceType>7012</differenceType>
+    </difference>
+    <difference>
+        <className>com/google/cloud/storage/Storage</className>
+        <method>com.google.cloud.storage.PostPolicyV4 generateSignedPostPolicyV4(com.google.cloud.storage.BlobInfo, long, java.util.concurrent.TimeUnit, com.google.cloud.storage.PostPolicyV4$PostConditionsV4, com.google.cloud.storage.Storage$PostPolicyV4Option[])</method>
+        <differenceType>7012</differenceType>
+    </difference>
+    <difference>
+        <className>com/google/cloud/storage/Storage</className>
+        <method>com.google.cloud.storage.PostPolicyV4 generateSignedPostPolicyV4(com.google.cloud.storage.BlobInfo, long, java.util.concurrent.TimeUnit, com.google.cloud.storage.Storage$PostPolicyV4Option[])</method>
+        <differenceType>7012</differenceType>
+    </difference>
+</differences>
\ No newline at end of file
diff --git a/google-cloud-storage/pom.xml b/google-cloud-storage/pom.xml
index 1a52a003..67da3521 100644
--- a/google-cloud-storage/pom.xml
+++ b/google-cloud-storage/pom.xml
@@ -40,6 +40,10 @@
       <groupId>com.google.apis</groupId>
       <artifactId>google-api-services-storage</artifactId>
     </dependency>
+    <dependency>
+      <groupId>com.google.code.gson</groupId>
+      <artifactId>gson</artifactId>
+    </dependency>
     <dependency>
       <groupId>com.google.cloud</groupId>
       <artifactId>google-cloud-core</artifactId>
@@ -174,7 +178,16 @@
     <dependency>
       <groupId>org.apache.httpcomponents</groupId>
       <artifactId>httpclient</artifactId>
-      <version>4.5.12</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.httpcomponents</groupId>
+      <artifactId>httpmime</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.httpcomponents</groupId>
+      <artifactId>httpcore</artifactId>
       <scope>test</scope>
     </dependency>
   </dependencies>
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
new file mode 100644
index 00000000..df2936dc
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/PostPolicyV4.java
@@ -0,0 +1,389 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.storage;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import java.text.SimpleDateFormat;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Presigned V4 post policy.
+ *
+ * @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 PostPolicyV4(String url, Map<String, String> fields) {
+    this.url = url;
+    this.fields = fields;
+  }
+
+  public static PostPolicyV4 of(String url, Map<String, String> fields) {
+    return new PostPolicyV4(url, fields);
+  }
+
+  public String getUrl() {
+    return url;
+  }
+
+  public Map<String, String> getFields() {
+    return fields;
+  }
+
+  /**
+   * Class representing which fields to specify in a V4 POST request.
+   *
+   * @see <a href="https://cloud.google.com/storage/docs/xml-api/post-object#form_fields">POST
+   *     Object Form fields</a>
+   */
+  public static final class PostFieldsV4 {
+    private Map<String, String> fieldsMap;
+
+    private PostFieldsV4(Builder builder) {
+      this.fieldsMap = builder.fieldsMap;
+    }
+
+    private PostFieldsV4(Map<String, String> fields) {
+      this.fieldsMap = fields;
+    }
+
+    public static PostFieldsV4 of(Map<String, String> fields) {
+      return new PostFieldsV4(fields);
+    }
+
+    public static Builder newBuilder() {
+      return new Builder();
+    }
+
+    public Map<String, String> getFieldsMap() {
+      return fieldsMap;
+    }
+
+    public static class Builder {
+      private Map<String, String> fieldsMap;
+
+      private Builder() {
+        fieldsMap = new HashMap<>();
+      }
+
+      public PostFieldsV4 build() {
+        return new PostFieldsV4(this);
+      }
+
+      public Builder setAcl(String acl) {
+        fieldsMap.put("acl", acl);
+        return this;
+      }
+
+      public Builder setCacheControl(String cacheControl) {
+        fieldsMap.put("cache-control", cacheControl);
+        return this;
+      }
+
+      public Builder setContentDisposition(String contentDisposition) {
+        fieldsMap.put("content-disposition", contentDisposition);
+        return this;
+      }
+
+      public Builder setContentEncoding(String contentEncoding) {
+        fieldsMap.put("content-encoding", contentEncoding);
+        return this;
+      }
+
+      public Builder setContentLength(int contentLength) {
+        fieldsMap.put("content-length", "" + contentLength);
+        return this;
+      }
+
+      public Builder setContentType(String contentType) {
+        fieldsMap.put("content-type", contentType);
+        return this;
+      }
+
+      public Builder Expires(String expires) {
+        fieldsMap.put("expires", expires);
+        return this;
+      }
+
+      public Builder setSuccessActionRedirect(String successActionRedirect) {
+        fieldsMap.put("success_action_redirect", successActionRedirect);
+        return this;
+      }
+
+      public Builder setSuccessActionStatus(int successActionStatus) {
+        fieldsMap.put("success_action_status", "" + successActionStatus);
+        return this;
+      }
+
+      public Builder AddCustomMetadataField(String field, String value) {
+        fieldsMap.put("x-goog-meta-" + field, value);
+        return this;
+      }
+    }
+  }
+
+  /**
+   * Class for specifying conditions in a V4 POST Policy document.
+   *
+   * @see <a href="https://cloud.google.com/storage/docs/authentication/signatures#policy-document">
+   *     Policy document</a>
+   */
+  public static final class PostConditionsV4 {
+    private Set<ConditionV4> conditions;
+
+    private static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
+
+    public PostConditionsV4(Builder builder) {
+      this.conditions = builder.conditions;
+    }
+
+    public Builder toBuilder() {
+      return new Builder(conditions);
+    }
+
+    public static Builder newBuilder() {
+      return new Builder();
+    }
+
+    public Set<ConditionV4> getConditions() {
+      return conditions;
+    }
+
+    public static class Builder {
+      Set<ConditionV4> conditions;
+
+      private Builder() {
+        this.conditions = new LinkedHashSet<>();
+      }
+
+      private Builder(Set<ConditionV4> conditions) {
+        this.conditions = conditions;
+      }
+
+      public static Builder newBuilder() {
+        return new Builder();
+      }
+
+      public PostConditionsV4 build() {
+        return new PostConditionsV4(this);
+      }
+
+      public Builder addAclCondition(ConditionV4Type type, String acl) {
+        conditions.add(new ConditionV4(type, "acl", acl));
+        return this;
+      }
+
+      public Builder addBucketCondition(ConditionV4Type type, String bucket) {
+        conditions.add(new ConditionV4(type, "bucket", bucket));
+        return this;
+      }
+
+      public Builder addCacheControlCondition(ConditionV4Type type, String cacheControl) {
+        conditions.add(new ConditionV4(type, "cache-control", cacheControl));
+        return this;
+      }
+
+      public Builder addContentDispositionCondition(
+          ConditionV4Type type, String contentDisposition) {
+        conditions.add(new ConditionV4(type, "content-disposition", contentDisposition));
+        return this;
+      }
+
+      public Builder addContentEncodingCondition(ConditionV4Type type, String contentEncoding) {
+        conditions.add(new ConditionV4(type, "content-encoding", contentEncoding));
+        return this;
+      }
+
+      public Builder addContentLengthCondition(ConditionV4Type type, int contentLength) {
+        conditions.add(new ConditionV4(type, "content-length", "" + contentLength));
+        return this;
+      }
+
+      public Builder addContentTypeCondition(ConditionV4Type type, String contentType) {
+        conditions.add(new ConditionV4(type, "content-type", contentType));
+        return this;
+      }
+
+      public Builder addExpiresCondition(ConditionV4Type type, long expires) {
+        conditions.add(new ConditionV4(type, "expires", dateFormat.format(expires)));
+        return this;
+      }
+
+      public Builder addExpiresCondition(ConditionV4Type type, String expires) {
+        conditions.add(new ConditionV4(type, "expires", expires));
+        return this;
+      }
+
+      public Builder addKeyCondition(ConditionV4Type type, String key) {
+        conditions.add(new ConditionV4(type, "key", key));
+        return this;
+      }
+
+      public Builder addSuccessActionRedirectUrlCondition(
+          ConditionV4Type type, String successActionRedirectUrl) {
+        conditions.add(new ConditionV4(type, "success_action_redirect", successActionRedirectUrl));
+        return this;
+      }
+
+      public Builder addSuccessActionStatusCondition(ConditionV4Type type, int status) {
+        conditions.add(new ConditionV4(type, "success_action_status", "" + status));
+        return this;
+      }
+
+      public Builder addContentLengthRangeCondition(int min, int max) {
+        conditions.add(new ConditionV4(ConditionV4Type.CONTENT_LENGTH_RANGE, "" + min, "" + max));
+        return this;
+      }
+
+      Builder addCustomCondition(ConditionV4Type type, String field, String value) {
+        conditions.add(new ConditionV4(type, field, value));
+        return this;
+      }
+    }
+  }
+
+  /**
+   * Class for a V4 POST Policy document.
+   *
+   * @see <a href="https://cloud.google.com/storage/docs/authentication/signatures#policy-document">
+   *     Policy document</a>
+   */
+  public static final class PostPolicyV4Document {
+    private String expiration;
+    private PostConditionsV4 conditions;
+
+    private PostPolicyV4Document(String expiration, PostConditionsV4 conditions) {
+      this.expiration = expiration;
+      this.conditions = conditions;
+    }
+
+    public static PostPolicyV4Document of(String expiration, PostConditionsV4 conditions) {
+      return new PostPolicyV4Document(expiration, conditions);
+    }
+
+    public String toJson() {
+      JsonObject object = new JsonObject();
+      JsonArray conditions = new JsonArray();
+      for (ConditionV4 condition : this.conditions.conditions) {
+        switch (condition.type) {
+          case MATCHES:
+            JsonObject match = new JsonObject();
+            match.addProperty(condition.operand1, condition.operand2);
+            conditions.add(match);
+            break;
+          case STARTS_WITH:
+            JsonArray startsWith = new JsonArray();
+            startsWith.add("starts-with");
+            startsWith.add("$" + condition.operand1);
+            startsWith.add(condition.operand2);
+            conditions.add(startsWith);
+            break;
+          case CONTENT_LENGTH_RANGE:
+            JsonArray contentLengthRange = new JsonArray();
+            contentLengthRange.add("content-length-range");
+            contentLengthRange.add(Integer.parseInt(condition.operand1));
+            contentLengthRange.add(Integer.parseInt(condition.operand2));
+            conditions.add(contentLengthRange);
+            break;
+        }
+      }
+      object.add("conditions", conditions);
+      object.addProperty("expiration", expiration);
+
+      String json = object.toString();
+      StringBuilder escapedJson = new StringBuilder();
+
+      // Certain characters in a policy must be escaped
+      for (char c : json.toCharArray()) {
+        if (c >= 128) { // is a unicode character
+          escapedJson.append(String.format("\\u%04x", (int) c));
+        } else {
+          switch (c) {
+            case '\\':
+              escapedJson.append("\\\\");
+              break;
+            case '\b':
+              escapedJson.append("\\b");
+              break;
+            case '\f':
+              escapedJson.append("\\f");
+              break;
+            case '\n':
+              escapedJson.append("\\n");
+              break;
+            case '\r':
+              escapedJson.append("\\r");
+              break;
+            case '\t':
+              escapedJson.append("\\t");
+              break;
+            case '\u000b':
+              escapedJson.append("\\v");
+              break;
+            default:
+              escapedJson.append(c);
+          }
+        }
+      }
+      return escapedJson.toString();
+    }
+  }
+
+  public enum ConditionV4Type {
+    MATCHES,
+    STARTS_WITH,
+    CONTENT_LENGTH_RANGE
+  }
+
+  /**
+   * Class for a specific POST policy document condition.
+   *
+   * @see <a href="https://cloud.google.com/storage/docs/authentication/signatures#policy-document">
+   *     Policy document</a>
+   */
+  static final class ConditionV4 {
+    ConditionV4Type type;
+    String operand1;
+    String operand2;
+
+    private ConditionV4(ConditionV4Type type, String operand1, String operand2) {
+      this.type = type;
+      this.operand1 = operand1;
+      this.operand2 = operand2;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      ConditionV4 condition = (ConditionV4) other;
+      return this.type == condition.type
+          && this.operand1.equals(condition.operand1)
+          && this.operand2.equals(condition.operand2);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(type, operand1, operand2);
+    }
+  }
+}
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 eb22eddc..38794cd4 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
@@ -32,6 +32,8 @@ import com.google.cloud.Tuple;
 import com.google.cloud.WriteChannel;
 import com.google.cloud.storage.Acl.Entity;
 import com.google.cloud.storage.HmacKey.HmacKeyMetadata;
+import com.google.cloud.storage.PostPolicyV4.PostConditionsV4;
+import com.google.cloud.storage.PostPolicyV4.PostFieldsV4;
 import com.google.cloud.storage.spi.v1.StorageRpc;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -1041,7 +1043,7 @@ public interface Storage extends Service<StorageOptions> {
     /**
      * If set to {@code true}, lists all versions of a blob. The default is {@code false}.
      *
-     * @see <a href ="https://cloud.google.com/storage/docs/object-versioning">Object Versioning</a>
+     * @see <a href="https://cloud.google.com/storage/docs/object-versioning">Object Versioning</a>
      */
     public static BlobListOption versions(boolean versions) {
       return new BlobListOption(StorageRpc.Option.VERSIONS, versions);
@@ -1060,6 +1062,107 @@ public interface Storage extends Service<StorageOptions> {
     }
   }
 
+  /** Class for specifying Post Policy V4 options. * */
+  class PostPolicyV4Option implements Serializable {
+    private static final long serialVersionUID = 8150867146534084543L;
+    private final PostPolicyV4Option.Option option;
+    private final Object value;
+
+    enum Option {
+      PATH_STYLE,
+      VIRTUAL_HOSTED_STYLE,
+      BUCKET_BOUND_HOST_NAME,
+      SERVICE_ACCOUNT_CRED
+    }
+
+    private PostPolicyV4Option(Option option, Object value) {
+      this.option = option;
+      this.value = value;
+    }
+
+    PostPolicyV4Option.Option getOption() {
+      return option;
+    }
+
+    Object getValue() {
+      return value;
+    }
+
+    /**
+     * Provides a service account signer to sign the policy. If not provided an attempt is made to
+     * get it from the environment.
+     *
+     * @see <a href="https://cloud.google.com/storage/docs/authentication#service_accounts">Service
+     *     Accounts</a>
+     */
+    public static PostPolicyV4Option signWith(ServiceAccountSigner signer) {
+      return new PostPolicyV4Option(PostPolicyV4Option.Option.SERVICE_ACCOUNT_CRED, signer);
+    }
+
+    /**
+     * Use a virtual hosted-style hostname, which adds the bucket into the host portion of the URI
+     * rather than the path, e.g. 'https://mybucket.storage.googleapis.com/...'. The bucket name is
+     * obtained from the resource passed in.
+     *
+     * @see <a href="https://cloud.google.com/storage/docs/request-endpoints">Request Endpoints</a>
+     */
+    public static PostPolicyV4Option withVirtualHostedStyle() {
+      return new PostPolicyV4Option(PostPolicyV4Option.Option.VIRTUAL_HOSTED_STYLE, "");
+    }
+
+    /**
+     * Generates a path-style URL, which places the bucket name in the path portion of the URL
+     * instead of in the hostname, e.g 'https://storage.googleapis.com/mybucket/...'. Note that this
+     * cannot be used alongside {@code withVirtualHostedStyle()}. Virtual hosted-style URLs, which
+     * can be used via the {@code withVirtualHostedStyle()} method, should generally be preferred
+     * instead of path-style URLs.
+     *
+     * @see <a href="https://cloud.google.com/storage/docs/request-endpoints">Request Endpoints</a>
+     */
+    public static PostPolicyV4Option withPathStyle() {
+      return new PostPolicyV4Option(PostPolicyV4Option.Option.PATH_STYLE, "");
+    }
+
+    /**
+     * Use a bucket-bound hostname, which replaces the storage.googleapis.com host with the name of
+     * a CNAME bucket, e.g. a bucket named 'gcs-subdomain.my.domain.tld', or a Google Cloud Load
+     * Balancer which routes to a bucket you own, e.g. 'my-load-balancer-domain.tld'. Note that this
+     * cannot be used alongside {@code withVirtualHostedStyle()} or {@code withPathStyle()}. This
+     * method signature uses HTTP for the URI scheme, and is equivalent to calling {@code
+     * withBucketBoundHostname("...", UriScheme.HTTP).}
+     *
+     * @see <a href="https://cloud.google.com/storage/docs/request-endpoints#cname">CNAME
+     *     Redirects</a>
+     * @see <a
+     *     href="https://cloud.google.com/load-balancing/docs/https/adding-backend-buckets-to-load-balancers">
+     *     GCLB Redirects</a>
+     */
+    public static PostPolicyV4Option withBucketBoundHostname(String bucketBoundHostname) {
+      return withBucketBoundHostname(bucketBoundHostname, Storage.UriScheme.HTTP);
+    }
+
+    /**
+     * Use a bucket-bound hostname, which replaces the storage.googleapis.com host with the name of
+     * a CNAME bucket, e.g. a bucket named 'gcs-subdomain.my.domain.tld', or a Google Cloud Load
+     * Balancer which routes to a bucket you own, e.g. 'my-load-balancer-domain.tld'. Note that this
+     * cannot be used alongside {@code withVirtualHostedStyle()} or {@code withPathStyle()}. The
+     * bucket name itself should not include the URI scheme (http or https), so it is specified via
+     * a local enum.
+     *
+     * @see <a href="https://cloud.google.com/storage/docs/request-endpoints#cname">CNAME
+     *     Redirects</a>
+     * @see <a
+     *     href="https://cloud.google.com/load-balancing/docs/https/adding-backend-buckets-to-load-balancers">
+     *     GCLB Redirects</a>
+     */
+    public static PostPolicyV4Option withBucketBoundHostname(
+        String bucketBoundHostname, Storage.UriScheme uriScheme) {
+      return new PostPolicyV4Option(
+          PostPolicyV4Option.Option.BUCKET_BOUND_HOST_NAME,
+          uriScheme.getScheme() + "://" + bucketBoundHostname);
+    }
+  }
+
   /** Class for specifying signed URL options. */
   class SignUrlOption implements Serializable {
 
@@ -1154,8 +1257,8 @@ public interface Storage extends Service<StorageOptions> {
     }
 
     /**
-     * Provides a service account signer to sign the URL. If not provided an attempt will be made to
-     * get it from the environment.
+     * Provides a service account signer to sign the URL. If not provided an attempt is made to get
+     * it from the environment.
      *
      * @see <a href="https://cloud.google.com/storage/docs/authentication#service_accounts">Service
      *     Accounts</a>
@@ -1167,7 +1270,7 @@ public interface Storage extends Service<StorageOptions> {
     /**
      * Use a different host name than the default host name 'storage.googleapis.com'. This option is
      * particularly useful for developers to point requests to an alternate endpoint (e.g. a staging
-     * environment or sending requests through VPC). Note that if using this with the {@code
+     * environment or sending requests through VPC). If using this with the {@code
      * withVirtualHostedStyle()} method, you should omit the bucket name from the hostname, as it
      * automatically gets prepended to the hostname for virtual hosted-style URLs.
      */
@@ -1177,10 +1280,10 @@ public interface Storage extends Service<StorageOptions> {
 
     /**
      * Use a virtual hosted-style hostname, which adds the bucket into the host portion of the URI
-     * rather than the path, e.g. 'https://mybucket.storage.googleapis.com/...'. The bucket name
-     * will be obtained from the resource passed in. For V4 signing, this also sets the "host"
-     * header in the canonicalized extension headers to the virtual hosted-style host, unless that
-     * header is supplied via the {@code withExtHeaders()} method.
+     * rather than the path, e.g. 'https://mybucket.storage.googleapis.com/...'. The bucket name is
+     * obtained from the resource passed in. For V4 signing, this also sets the "host" header in the
+     * canonicalized extension headers to the virtual hosted-style host, unless that header is
+     * supplied via the {@code withExtHeaders()} method.
      *
      * @see <a href="https://cloud.google.com/storage/docs/request-endpoints">Request Endpoints</a>
      */
@@ -1189,11 +1292,11 @@ public interface Storage extends Service<StorageOptions> {
     }
 
     /**
-     * Generate a path-style URL, which places the bucket name in the path portion of the URL
-     * instead of in the hostname, e.g 'https://storage.googleapis.com/mybucket/...'. Note that this
-     * cannot be used alongside {@code withVirtualHostedStyle()}. Virtual hosted-style URLs, which
-     * can be used via the {@code withVirtualHostedStyle()} method, should generally be preferred
-     * instead of path-style URLs.
+     * Generates a path-style URL, which places the bucket name in the path portion of the URL
+     * instead of in the hostname, e.g 'https://storage.googleapis.com/mybucket/...'. This cannot be
+     * used alongside {@code withVirtualHostedStyle()}. Virtual hosted-style URLs, which can be used
+     * via the {@code withVirtualHostedStyle()} method, should generally be preferred instead of
+     * path-style URLs.
      *
      * @see <a href="https://cloud.google.com/storage/docs/request-endpoints">Request Endpoints</a>
      */
@@ -1204,9 +1307,9 @@ public interface Storage extends Service<StorageOptions> {
     /**
      * Use a bucket-bound hostname, which replaces the storage.googleapis.com host with the name of
      * a CNAME bucket, e.g. a bucket named 'gcs-subdomain.my.domain.tld', or a Google Cloud Load
-     * Balancer which routes to a bucket you own, e.g. 'my-load-balancer-domain.tld'. Note that this
-     * cannot be used alongside {@code withVirtualHostedStyle()} or {@code withPathStyle()}. This
-     * method signature uses HTTP for the URI scheme, and is equivalent to calling {@code
+     * Balancer which routes to a bucket you own, e.g. 'my-load-balancer-domain.tld'. This cannot be
+     * used alongside {@code withVirtualHostedStyle()} or {@code withPathStyle()}. This method
+     * signature uses HTTP for the URI scheme, and is equivalent to calling {@code
      * withBucketBoundHostname("...", UriScheme.HTTP).}
      *
      * @see <a href="https://cloud.google.com/storage/docs/request-endpoints#cname">CNAME
@@ -1896,6 +1999,7 @@ public interface Storage extends Service<StorageOptions> {
    * only if supplied Decrpytion Key decrypts the blob successfully, otherwise a {@link
    * StorageException} is thrown. For more information review
    *
+   * @throws StorageException upon failure
    * @see <a
    *     href="https://cloud.google.com/storage/docs/encryption/customer-supplied-keys#encrypted-elements">Encrypted
    *     Elements</a>
@@ -2547,6 +2651,96 @@ public interface Storage extends Service<StorageOptions> {
    */
   URL signUrl(BlobInfo blobInfo, long duration, TimeUnit unit, SignUrlOption... options);
 
+  /**
+   * Generates a URL and a map of fields that can be specified in an HTML form to submit a POST
+   * request. The returned map includes a signature which must be provided with the request.
+   * Generating a presigned POST policy requires a service account signer. If an instance of {@link
+   * com.google.auth.ServiceAccountSigner} was passed to {@link StorageOptions}' builder via {@code
+   * setCredentials(Credentials)} or the default credentials are being used and the environment
+   * variable {@code GOOGLE_APPLICATION_CREDENTIALS} is set, generatPresignedPostPolicyV4 will use
+   * that credentials to sign the URL. If the credentials passed to {@link StorageOptions} do not
+   * implement {@link ServiceAccountSigner} (this is the case, for instance, for Google Cloud SDK
+   * credentials) then {@code signUrl} will throw an {@link IllegalStateException} unless an
+   * implementation of {@link ServiceAccountSigner} is passed using the {@link
+   * PostPolicyV4Option#signWith(ServiceAccountSigner)} option.
+   *
+   * <p>Example of generating a presigned post policy which has the condition that only jpeg images
+   * can be uploaded, and applies the public read acl to each image uploaded, and making the POST
+   * request:
+   *
+   * <pre>{@code
+   * PostFieldsV4 fields = PostFieldsV4.newBuilder().setAcl("public-read").build();
+   * PostConditionsV4 conditions = PostConditionsV4.newBuilder().addContentTypeCondition(ConditionV4Type.MATCHES, "image/jpeg").build();
+   *
+   * PostPolicyV4 policy = storage.generateSignedPostPolicyV4(
+   *     BlobInfo.newBuilder("my-bucket", "my-object").build(),
+   *     7, TimeUnit.DAYS, fields, conditions);
+   *
+   * HttpClient client = HttpClientBuilder.create().build();
+   * HttpPost request = new HttpPost(policy.getUrl());
+   * MultipartEntityBuilder builder = MultipartEntityBuilder.create();
+   *
+   * for (Map.Entry<String, String> entry : policy.getFields().entrySet()) {
+   *     builder.addTextBody(entry.getKey(), entry.getValue());
+   * }
+   * File file = new File("path/to/your/file/to/upload");
+   * builder.addBinaryBody("file", new FileInputStream(file), ContentType.APPLICATION_OCTET_STREAM, file.getName());
+   * request.setEntity(builder.build());
+   * client.execute(request);
+   * }</pre>
+   *
+   * @param blobInfo the blob uploaded in the form
+   * @param fields the fields specified in the form
+   * @param conditions which conditions every upload must satisfy
+   * @param duration how long until the form expires, in milliseconds
+   * @param options optional post policy options
+   * @see <a
+   *     href="https://cloud.google.com/storage/docs/xml-api/post-object#usage_and_examples">POST
+   *     Object</a>
+   */
+  PostPolicyV4 generateSignedPostPolicyV4(
+      BlobInfo blobInfo,
+      long duration,
+      TimeUnit unit,
+      PostFieldsV4 fields,
+      PostConditionsV4 conditions,
+      PostPolicyV4Option... options);
+
+  /**
+   * Generates a presigned post policy without any conditions. Automatically creates required
+   * conditions. See full documentation for generateSignedPostPolicyV4( BlobInfo blobInfo, long
+   * duration, TimeUnit unit, PostFieldsV4 fields, PostConditionsV4 conditions,
+   * PostPolicyV4Option... options) above.
+   */
+  PostPolicyV4 generateSignedPostPolicyV4(
+      BlobInfo blobInfo,
+      long duration,
+      TimeUnit unit,
+      PostFieldsV4 fields,
+      PostPolicyV4Option... options);
+
+  /**
+   * Generates a presigned post policy without any fields. Automatically creates required fields.
+   * See full documentation for generateSignedPostPolicyV4( BlobInfo blobInfo, long duration,
+   * TimeUnit unit, PostFieldsV4 fields, PostConditionsV4 conditions, PostPolicyV4Option... options)
+   * above.
+   */
+  PostPolicyV4 generateSignedPostPolicyV4(
+      BlobInfo blobInfo,
+      long duration,
+      TimeUnit unit,
+      PostConditionsV4 conditions,
+      PostPolicyV4Option... options);
+
+  /**
+   * Generates a presigned post policy without any fields or conditions. Automatically creates
+   * required fields and conditions. See full documentation for generateSignedPostPolicyV4( BlobInfo
+   * blobInfo, long duration, TimeUnit unit, PostFieldsV4 fields, PostConditionsV4 conditions,
+   * PostPolicyV4Option... options) above.
+   */
+  PostPolicyV4 generateSignedPostPolicyV4(
+      BlobInfo blobInfo, long duration, TimeUnit unit, PostPolicyV4Option... options);
+
   /**
    * Gets the requested blobs. A batch request is used to perform this call.
    *
@@ -3161,6 +3355,7 @@ public interface Storage extends Service<StorageOptions> {
       final HmacKeyMetadata hmacKeyMetadata,
       final HmacKey.HmacKeyState state,
       UpdateHmacKeyOption... options);
+
   /**
    * Gets the IAM policy for the provided bucket.
    *
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 0f38e382..0e24521e 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
@@ -50,6 +50,10 @@ import com.google.cloud.RetryHelper.RetryHelperException;
 import com.google.cloud.Tuple;
 import com.google.cloud.storage.Acl.Entity;
 import com.google.cloud.storage.HmacKey.HmacKeyMetadata;
+import com.google.cloud.storage.PostPolicyV4.ConditionV4Type;
+import com.google.cloud.storage.PostPolicyV4.PostConditionsV4;
+import com.google.cloud.storage.PostPolicyV4.PostFieldsV4;
+import com.google.cloud.storage.PostPolicyV4.PostPolicyV4Document;
 import com.google.cloud.storage.spi.v1.StorageRpc;
 import com.google.cloud.storage.spi.v1.StorageRpc.RewriteResponse;
 import com.google.common.base.CharMatcher;
@@ -72,12 +76,15 @@ import java.net.MalformedURLException;
 import java.net.URI;
 import java.net.URL;
 import java.net.URLEncoder;
+import java.text.SimpleDateFormat;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.EnumMap;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.TimeZone;
 import java.util.concurrent.Callable;
 import java.util.concurrent.TimeUnit;
 
@@ -733,6 +740,139 @@ final class StorageImpl extends BaseService<StorageOptions> implements Storage {
     }
   }
 
+  @Override
+  public PostPolicyV4 generateSignedPostPolicyV4(
+      BlobInfo blobInfo,
+      long duration,
+      TimeUnit unit,
+      PostFieldsV4 fields,
+      PostConditionsV4 conditions,
+      PostPolicyV4Option... options) {
+    EnumMap<SignUrlOption.Option, Object> optionMap = Maps.newEnumMap(SignUrlOption.Option.class);
+    // Convert to a map of SignUrlOptions so we can re-use some utility methods
+    for (PostPolicyV4Option option : options) {
+      optionMap.put(SignUrlOption.Option.valueOf(option.getOption().name()), option.getValue());
+    }
+
+    optionMap.put(SignUrlOption.Option.SIGNATURE_VERSION, SignUrlOption.SignatureVersion.V4);
+
+    ServiceAccountSigner credentials =
+        (ServiceAccountSigner) optionMap.get(SignUrlOption.Option.SERVICE_ACCOUNT_CRED);
+    if (credentials == null) {
+      checkState(
+          this.getOptions().getCredentials() instanceof ServiceAccountSigner,
+          "Signing key was not provided and could not be derived");
+      credentials = (ServiceAccountSigner) this.getOptions().getCredentials();
+    }
+
+    checkArgument(
+        !(optionMap.containsKey(SignUrlOption.Option.VIRTUAL_HOSTED_STYLE)
+            && optionMap.containsKey(SignUrlOption.Option.PATH_STYLE)
+            && optionMap.containsKey(SignUrlOption.Option.BUCKET_BOUND_HOST_NAME)),
+        "Only one of VIRTUAL_HOSTED_STYLE, PATH_STYLE, or BUCKET_BOUND_HOST_NAME SignUrlOptions can be"
+            + " specified.");
+
+    String bucketName = slashlessBucketNameFromBlobInfo(blobInfo);
+
+    boolean usePathStyle = shouldUsePathStyleForSignedUrl(optionMap);
+
+    String url;
+
+    if (usePathStyle) {
+      url = STORAGE_XML_URI_SCHEME + "://" + STORAGE_XML_URI_HOST_NAME + "/" + bucketName + "/";
+    } else {
+      url = STORAGE_XML_URI_SCHEME + "://" + bucketName + "." + STORAGE_XML_URI_HOST_NAME + "/";
+    }
+
+    if (optionMap.containsKey(SignUrlOption.Option.BUCKET_BOUND_HOST_NAME)) {
+      url = optionMap.get(SignUrlOption.Option.BUCKET_BOUND_HOST_NAME) + "/";
+    }
+
+    SimpleDateFormat googDateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
+    SimpleDateFormat yearMonthDayFormat = new SimpleDateFormat("yyyyMMdd");
+    SimpleDateFormat expirationFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
+    googDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+    yearMonthDayFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+    expirationFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+
+    long timestamp = getOptions().getClock().millisTime();
+    String date = googDateFormat.format(timestamp);
+    String signingCredential =
+        credentials.getAccount()
+            + "/"
+            + yearMonthDayFormat.format(timestamp)
+            + "/auto/storage/goog4_request";
+
+    Map<String, String> policyFields = new HashMap<>();
+
+    PostConditionsV4.Builder conditionsBuilder = conditions.toBuilder();
+
+    for (Map.Entry<String, String> entry : fields.getFieldsMap().entrySet()) {
+      // Every field needs a corresponding policy condition, so add them if they're missing
+      conditionsBuilder.addCustomCondition(
+          ConditionV4Type.MATCHES, entry.getKey(), entry.getValue());
+
+      policyFields.put(entry.getKey(), entry.getValue());
+    }
+
+    PostConditionsV4 v4Conditions =
+        conditionsBuilder
+            .addBucketCondition(ConditionV4Type.MATCHES, blobInfo.getBucket())
+            .addKeyCondition(ConditionV4Type.MATCHES, blobInfo.getName())
+            .addCustomCondition(ConditionV4Type.MATCHES, "x-goog-date", date)
+            .addCustomCondition(ConditionV4Type.MATCHES, "x-goog-credential", signingCredential)
+            .addCustomCondition(ConditionV4Type.MATCHES, "x-goog-algorithm", "GOOG4-RSA-SHA256")
+            .build();
+    PostPolicyV4Document document =
+        PostPolicyV4Document.of(
+            expirationFormat.format(timestamp + unit.toMillis(duration)), v4Conditions);
+    String policy = BaseEncoding.base64().encode(document.toJson().getBytes());
+    String signature =
+        BaseEncoding.base16().encode(credentials.sign(policy.getBytes())).toLowerCase();
+
+    for (PostPolicyV4.ConditionV4 condition : v4Conditions.getConditions()) {
+      if (condition.type == ConditionV4Type.MATCHES) {
+        policyFields.put(condition.operand1, condition.operand2);
+      }
+    }
+    policyFields.put("key", blobInfo.getName());
+    policyFields.put("x-goog-credential", signingCredential);
+    policyFields.put("x-goog-algorithm", "GOOG4-RSA-SHA256");
+    policyFields.put("x-goog-date", date);
+    policyFields.put("x-goog-signature", signature);
+    policyFields.put("policy", policy);
+
+    policyFields.remove("bucket");
+
+    return PostPolicyV4.of(url, policyFields);
+  }
+
+  public PostPolicyV4 generateSignedPostPolicyV4(
+      BlobInfo blobInfo,
+      long duration,
+      TimeUnit unit,
+      PostFieldsV4 fields,
+      PostPolicyV4Option... options) {
+    return generateSignedPostPolicyV4(
+        blobInfo, duration, unit, fields, PostConditionsV4.newBuilder().build(), options);
+  }
+
+  public PostPolicyV4 generateSignedPostPolicyV4(
+      BlobInfo blobInfo,
+      long duration,
+      TimeUnit unit,
+      PostConditionsV4 conditions,
+      PostPolicyV4Option... options) {
+    return generateSignedPostPolicyV4(
+        blobInfo, duration, unit, PostFieldsV4.newBuilder().build(), conditions, options);
+  }
+
+  public PostPolicyV4 generateSignedPostPolicyV4(
+      BlobInfo blobInfo, long duration, TimeUnit unit, PostPolicyV4Option... options) {
+    return generateSignedPostPolicyV4(
+        blobInfo, duration, unit, PostFieldsV4.newBuilder().build(), options);
+  }
+
   private String constructResourceUriPath(
       String slashlessBucketName,
       String escapedBlobName,
diff --git a/pom.xml b/pom.xml
index 618b3458..08faa8cf 100644
--- a/pom.xml
+++ b/pom.xml
@@ -86,6 +86,11 @@
         <type>pom</type>
         <scope>import</scope>
       </dependency>
+      <dependency>
+        <groupId>com.google.code.gson</groupId>
+        <artifactId>gson</artifactId>
+        <version>2.8.6</version>
+      </dependency>
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>google-cloud-core-bom</artifactId>
@@ -221,6 +226,24 @@
         <version>0.0.10</version>
         <scope>test</scope>
       </dependency>
+      <dependency>
+        <groupId>org.apache.httpcomponents</groupId>
+        <artifactId>httpclient</artifactId>
+        <version>4.5.12</version>
+        <scope>test</scope>
+      </dependency>
+      <dependency>
+        <groupId>org.apache.httpcomponents</groupId>
+        <artifactId>httpmime</artifactId>
+        <version>4.5.12</version>
+        <scope>test</scope>
+      </dependency>
+      <dependency>
+        <groupId>org.apache.httpcomponents</groupId>
+        <artifactId>httpcore</artifactId>
+        <version>4.4.13</version>
+        <scope>test</scope>
+      </dependency>
     </dependencies>
   </dependencyManagement>
 


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 55295a9d..3d590e00 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
@@ -3044,4 +3044,80 @@ public class StorageImplTest {
     assertNotNull(writer);
     assertTrue(writer.isOpen());
   }
+
+  @Test
+  public void testV4PostPolicy() {
+    EasyMock.replay(storageRpcMock);
+    ServiceAccountCredentials credentials =
+        ServiceAccountCredentials.newBuilder()
+            .setClientEmail(ACCOUNT)
+            .setPrivateKey(privateKey)
+            .build();
+    storage = options.toBuilder().setCredentials(credentials).build().getService();
+
+    PostPolicyV4.PostFieldsV4 fields =
+        PostPolicyV4.PostFieldsV4.newBuilder().setAcl("public-read").build();
+    PostPolicyV4.PostConditionsV4 conditions =
+        PostPolicyV4.PostConditionsV4.newBuilder()
+            .addContentTypeCondition(PostPolicyV4.ConditionV4Type.MATCHES, "image/jpeg")
+            .build();
+
+    // test fields and conditions
+    PostPolicyV4 policy =
+        storage.generateSignedPostPolicyV4(
+            BlobInfo.newBuilder("my-bucket", "my-object").build(),
+            7,
+            TimeUnit.DAYS,
+            fields,
+            conditions);
+
+    Map<String, String> outputFields = policy.getFields();
+
+    assertTrue(outputFields.containsKey("x-goog-date"));
+    assertTrue(outputFields.containsKey("x-goog-credential"));
+    assertTrue(outputFields.containsKey("x-goog-signature"));
+    assertEquals(outputFields.get("x-goog-algorithm"), "GOOG4-RSA-SHA256");
+    assertEquals(outputFields.get("content-type"), "image/jpeg");
+    assertEquals(outputFields.get("acl"), "public-read");
+    assertEquals(outputFields.get("key"), "my-object");
+    assertEquals("https://storage.googleapis.com/my-bucket/", policy.getUrl());
+
+    // test fields, no conditions
+    policy =
+        storage.generateSignedPostPolicyV4(
+            BlobInfo.newBuilder("my-bucket", "my-object").build(), 7, TimeUnit.DAYS, conditions);
+    outputFields = policy.getFields();
+
+    assertTrue(outputFields.containsKey("x-goog-date"));
+    assertTrue(outputFields.containsKey("x-goog-credential"));
+    assertTrue(outputFields.containsKey("x-goog-signature"));
+    assertEquals(outputFields.get("x-goog-algorithm"), "GOOG4-RSA-SHA256");
+    assertEquals(outputFields.get("content-type"), "image/jpeg");
+    assertEquals(outputFields.get("key"), "my-object");
+    assertEquals("https://storage.googleapis.com/my-bucket/", policy.getUrl());
+
+    // test conditions, no fields
+    policy =
+        storage.generateSignedPostPolicyV4(
+            BlobInfo.newBuilder("my-bucket", "my-object").build(), 7, TimeUnit.DAYS, fields);
+    outputFields = policy.getFields();
+    assertTrue(outputFields.containsKey("x-goog-date"));
+    assertTrue(outputFields.containsKey("x-goog-credential"));
+    assertTrue(outputFields.containsKey("x-goog-signature"));
+    assertEquals(outputFields.get("x-goog-algorithm"), "GOOG4-RSA-SHA256");
+    assertEquals(outputFields.get("acl"), "public-read");
+    assertEquals(outputFields.get("key"), "my-object");
+
+    // test no conditions no fields
+    policy =
+        storage.generateSignedPostPolicyV4(
+            BlobInfo.newBuilder("my-bucket", "my-object").build(), 7, TimeUnit.DAYS);
+    outputFields = policy.getFields();
+    assertTrue(outputFields.containsKey("x-goog-date"));
+    assertTrue(outputFields.containsKey("x-goog-credential"));
+    assertTrue(outputFields.containsKey("x-goog-signature"));
+    assertEquals(outputFields.get("x-goog-algorithm"), "GOOG4-RSA-SHA256");
+    assertEquals(outputFields.get("key"), "my-object");
+    assertEquals("https://storage.googleapis.com/my-bucket/", policy.getUrl());
+  }
 }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/V4PostPolicyTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/V4PostPolicyTest.java
new file mode 100644
index 00000000..161fb364
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/V4PostPolicyTest.java
@@ -0,0 +1,246 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.storage;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import com.google.api.core.ApiClock;
+import com.google.auth.oauth2.ServiceAccountCredentials;
+import com.google.cloud.conformance.storage.v1.PolicyConditions;
+import com.google.cloud.conformance.storage.v1.PolicyInput;
+import com.google.cloud.conformance.storage.v1.PostPolicyV4Test;
+import com.google.cloud.conformance.storage.v1.TestFile;
+import com.google.cloud.conformance.storage.v1.UrlStyle;
+import com.google.cloud.storage.testing.RemoteStorageHelper;
+import com.google.common.base.Charsets;
+import com.google.common.base.Strings;
+import com.google.common.io.BaseEncoding;
+import com.google.protobuf.Timestamp;
+import com.google.protobuf.util.JsonFormat;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestName;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class V4PostPolicyTest {
+
+  private static final String SERVICE_ACCOUNT_JSON_RESOURCE =
+      "com/google/cloud/conformance/storage/v1/test_service_account.not-a-test.json";
+  private static final String TEST_DATA_JSON_RESOURCE =
+      "com/google/cloud/conformance/storage/v1/v4_signatures.json";
+
+  private static class FakeClock implements ApiClock {
+    private final AtomicLong currentNanoTime;
+
+    public FakeClock(Timestamp timestamp) {
+      this.currentNanoTime =
+          new AtomicLong(
+              TimeUnit.NANOSECONDS.convert(timestamp.getSeconds(), TimeUnit.SECONDS)
+                  + timestamp.getNanos());
+    }
+
+    public long nanoTime() {
+      return this.currentNanoTime.get();
+    }
+
+    public long millisTime() {
+      return TimeUnit.MILLISECONDS.convert(this.nanoTime(), TimeUnit.NANOSECONDS);
+    }
+  }
+
+  @Rule public TestName testName = new TestName();
+
+  private final PostPolicyV4Test testData;
+  private final ServiceAccountCredentials serviceAccountCredentials;
+
+  /**
+   * @param testData the serialized test data representing the test case.
+   * @param serviceAccountCredentials The credentials to use in this test.
+   * @param description Not used by the test, but used by the parameterized test runner as the name
+   *     of the test.
+   */
+  public V4PostPolicyTest(
+      PostPolicyV4Test testData,
+      ServiceAccountCredentials serviceAccountCredentials,
+      @SuppressWarnings("unused") String description) {
+    this.testData = testData;
+    this.serviceAccountCredentials = serviceAccountCredentials;
+  }
+
+  @Test
+  public void test() {
+    Storage storage =
+        RemoteStorageHelper.create()
+            .getOptions()
+            .toBuilder()
+            .setCredentials(serviceAccountCredentials)
+            .setClock(new FakeClock(testData.getPolicyInput().getTimestamp()))
+            .build()
+            .getService();
+
+    BlobInfo blob =
+        BlobInfo.newBuilder(
+                testData.getPolicyInput().getBucket(), testData.getPolicyInput().getObject())
+            .build();
+
+    PolicyInput policyInput = testData.getPolicyInput();
+    PostPolicyV4.PostConditionsV4.Builder builder = PostPolicyV4.PostConditionsV4.newBuilder();
+
+    Map<String, String> fields = policyInput.getFieldsMap();
+
+    PolicyConditions conditions = policyInput.getConditions();
+
+    if (!Strings.isNullOrEmpty(fields.get("success_action_redirect"))) {
+      builder.addSuccessActionRedirectUrlCondition(
+          PostPolicyV4.ConditionV4Type.MATCHES, fields.get("success_action_redirect"));
+    }
+
+    if (!Strings.isNullOrEmpty(fields.get("success_action_status"))) {
+      builder.addSuccessActionStatusCondition(
+          PostPolicyV4.ConditionV4Type.MATCHES,
+          Integer.parseInt(fields.get("success_action_status")));
+    }
+
+    if (conditions != null) {
+      if (!conditions.getStartsWithList().isEmpty()) {
+        builder.addCustomCondition(
+            PostPolicyV4.ConditionV4Type.STARTS_WITH,
+            conditions.getStartsWith(0).replace("$", ""),
+            conditions.getStartsWith(1));
+      }
+      if (!conditions.getContentLengthRangeList().isEmpty()) {
+        builder.addContentLengthRangeCondition(
+            conditions.getContentLengthRange(0), conditions.getContentLengthRange(1));
+      }
+    }
+
+    PostPolicyV4.PostFieldsV4 v4Fields = PostPolicyV4.PostFieldsV4.of(fields);
+
+    Storage.PostPolicyV4Option style = Storage.PostPolicyV4Option.withPathStyle();
+
+    if (policyInput.getUrlStyle().equals(UrlStyle.VIRTUAL_HOSTED_STYLE)) {
+      style = Storage.PostPolicyV4Option.withVirtualHostedStyle();
+    } else if (policyInput.getUrlStyle().equals(UrlStyle.PATH_STYLE)) {
+      style = Storage.PostPolicyV4Option.withPathStyle();
+    } else if (policyInput.getUrlStyle().equals(UrlStyle.BUCKET_BOUND_HOSTNAME)) {
+      style =
+          Storage.PostPolicyV4Option.withBucketBoundHostname(
+              policyInput.getBucketBoundHostname(),
+              Storage.UriScheme.valueOf(policyInput.getScheme().toUpperCase()));
+    }
+
+    PostPolicyV4 policy =
+        storage.generateSignedPostPolicyV4(
+            blob,
+            testData.getPolicyInput().getExpiration(),
+            TimeUnit.SECONDS,
+            v4Fields,
+            builder.build(),
+            style);
+
+    String expectedPolicy = testData.getPolicyOutput().getExpectedDecodedPolicy();
+    StringBuilder escapedPolicy = new StringBuilder();
+
+    // Java automatically unescapes the unicode escapes in the conformance tests, so we need to
+    // manually re-escape them
+    for (char c : expectedPolicy.toCharArray()) {
+      if (c >= 128) {
+        escapedPolicy.append(String.format("\\u%04x", (int) c));
+      } else {
+        switch (c) {
+          case '\\':
+            escapedPolicy.append("\\\\");
+            break;
+          case '\b':
+            escapedPolicy.append("\\b");
+            break;
+          case '\f':
+            escapedPolicy.append("\\f");
+            break;
+          case '\n':
+            escapedPolicy.append("\\n");
+            break;
+          case '\r':
+            escapedPolicy.append("\\r");
+            break;
+          case '\t':
+            escapedPolicy.append("\\t");
+            break;
+          case '\u000b':
+            escapedPolicy.append("\\v");
+            break;
+          default:
+            escapedPolicy.append(c);
+        }
+      }
+    }
+    assertEquals(testData.getPolicyOutput().getFieldsMap(), policy.getFields());
+    assertEquals(
+        escapedPolicy.toString(),
+        new String(BaseEncoding.base64().decode(policy.getFields().get("policy"))));
+    assertEquals(testData.getPolicyOutput().getUrl(), policy.getUrl());
+  }
+
+  /**
+   * Loads all of the tests and return a {@code Collection<Object[]>} representing the set of tests.
+   * Each entry in the returned collection is the set of parameters to the constructor of this test
+   * class.
+   *
+   * <p>The results of this method will then be run by JUnit's Parameterized test runner
+   */
+  @Parameters(name = "{2}")
+  public static Collection<Object[]> testCases() throws IOException {
+    ClassLoader cl = Thread.currentThread().getContextClassLoader();
+
+    InputStream credentialsStream = cl.getResourceAsStream(SERVICE_ACCOUNT_JSON_RESOURCE);
+    assertNotNull(
+        String.format("Unable to load service account json: %s", SERVICE_ACCOUNT_JSON_RESOURCE),
+        credentialsStream);
+
+    InputStream dataJson = cl.getResourceAsStream(TEST_DATA_JSON_RESOURCE);
+    assertNotNull(
+        String.format("Unable to load test definition: %s", TEST_DATA_JSON_RESOURCE), dataJson);
+
+    ServiceAccountCredentials serviceAccountCredentials =
+        ServiceAccountCredentials.fromStream(credentialsStream);
+
+    InputStreamReader reader = new InputStreamReader(dataJson, Charsets.UTF_8);
+    TestFile.Builder testBuilder = TestFile.newBuilder();
+    JsonFormat.parser().merge(reader, testBuilder);
+    TestFile testFile = testBuilder.build();
+
+    List<PostPolicyV4Test> tests = testFile.getPostPolicyV4TestsList();
+    ArrayList<Object[]> data = new ArrayList<>(tests.size());
+    for (PostPolicyV4Test test : tests) {
+      data.add(new Object[] {test, serviceAccountCredentials, test.getDescription()});
+    }
+    return data;
+  }
+}
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 52a6acd7..e1fb5260 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
@@ -67,6 +67,8 @@ import com.google.cloud.storage.BucketInfo.LifecycleRule.LifecycleCondition;
 import com.google.cloud.storage.CopyWriter;
 import com.google.cloud.storage.HmacKey;
 import com.google.cloud.storage.HttpMethod;
+import com.google.cloud.storage.PostPolicyV4;
+import com.google.cloud.storage.PostPolicyV4.PostFieldsV4;
 import com.google.cloud.storage.ServiceAccount;
 import com.google.cloud.storage.Storage;
 import com.google.cloud.storage.Storage.BlobField;
@@ -98,11 +100,14 @@ import io.grpc.auth.MoreCallCredentials;
 import io.grpc.stub.MetadataUtils;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.URL;
 import java.net.URLConnection;
 import java.nio.ByteBuffer;
+import java.nio.file.Files;
 import java.security.Key;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -120,6 +125,11 @@ import java.util.logging.Level;
 import java.util.logging.Logger;
 import java.util.zip.GZIPInputStream;
 import javax.crypto.spec.SecretKeySpec;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.mime.MultipartEntityBuilder;
+import org.apache.http.impl.client.HttpClientBuilder;
 import org.apache.http.impl.client.HttpClients;
 import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
 import org.junit.AfterClass;
@@ -3208,4 +3218,29 @@ public class ITStorageTest {
       RemoteStorageHelper.forceDelete(storage, loggingBucket, 5, TimeUnit.SECONDS);
     }
   }
+
+  @Test
+  public void testSignedPostPolicyV4() throws Exception {
+    PostFieldsV4 fields = PostFieldsV4.newBuilder().setAcl("public-read").build();
+
+    PostPolicyV4 policy =
+        storage.generateSignedPostPolicyV4(
+            BlobInfo.newBuilder(BUCKET, "my-object").build(), 7, TimeUnit.DAYS, fields);
+
+    HttpClient client = HttpClientBuilder.create().build();
+    HttpPost request = new HttpPost(policy.getUrl());
+    MultipartEntityBuilder builder = MultipartEntityBuilder.create();
+
+    for (Map.Entry<String, String> entry : policy.getFields().entrySet()) {
+      builder.addTextBody(entry.getKey(), entry.getValue());
+    }
+    File file = File.createTempFile("temp", "file");
+    Files.write(file.toPath(), "hello world".getBytes());
+    builder.addBinaryBody(
+        "file", new FileInputStream(file), ContentType.APPLICATION_OCTET_STREAM, file.getName());
+    request.setEntity(builder.build());
+    client.execute(request);
+
+    assertEquals("hello world", new String(storage.get(BUCKET, "my-object").getContent()));
+  }
 }