Add support for uploading binary content through the edit rest api

Adding UI support will be done in a followup.

Change-Id: I3db1bc0503d83e58fadcc9642224fb8cec74ce92
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index f7019d6..677c246 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -2728,6 +2728,20 @@
   PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit/foo HTTP/1.0
 ----
 
+To upload a file as binary data in the request body:
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit/foo HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "binary_content": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="
+  }
+----
+
+Note that it must be base-64 encoded data uri.
+
 When change edit doesn't exist for this change yet it is created. When file
 content isn't provided, it is wiped out for that file. As response
 "`204 No Content`" is returned.
diff --git a/java/com/google/gerrit/extensions/api/changes/FileContentInput.java b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
index 93c253d..0cfe908 100644
--- a/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
@@ -20,4 +20,5 @@
 /** Content to be added to a file (new or existing) via change edit. */
 public class FileContentInput {
   @DefaultInput public RawInput content;
+  public String binary_content;
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 68e39e7..9a25f52 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -35,6 +36,7 @@
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -66,9 +68,12 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.Base64;
 import org.kohsuke.args4j.Option;
 
 @Singleton
@@ -277,6 +282,10 @@
   /** Put handler that is activated when PUT request is called on collection element. */
   @Singleton
   public static class Put implements RestModifyView<ChangeEditResource, FileContentInput> {
+    private static final Pattern BINARY_DATA_PATTERN =
+        Pattern.compile("data:([\\w/.-]*);([\\w]+),(.*)");
+    private static final String BASE64 = "base64";
+
     private final ChangeEditModifier editModifier;
     private final GitRepositoryManager repositoryManager;
     private final EditMessage editMessage;
@@ -301,22 +310,36 @@
     public Response<Object> apply(ChangeResource rsrc, String path, FileContentInput input)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
-      if (input.content == null) {
-        throw new BadRequestException("new content required");
+
+      if (input.content == null && input.binary_content == null) {
+        throw new BadRequestException("either content or binary_content is required");
       }
 
-      if (Patch.COMMIT_MSG.equals(path)) {
+      RawInput newContent;
+      if (input.binary_content != null) {
+        Matcher m = BINARY_DATA_PATTERN.matcher(input.binary_content);
+        if (m.matches() && BASE64.equals(m.group(2))) {
+          newContent = RawInputUtil.create(Base64.decode(m.group(3)));
+        } else {
+          throw new BadRequestException("binary_content must be encoded as base64 data uri");
+        }
+      } else {
+        newContent = input.content;
+      }
+
+      if (Patch.COMMIT_MSG.equals(path) && input.binary_content == null) {
         EditMessage.Input editCommitMessageInput = new EditMessage.Input();
         editCommitMessageInput.message =
-            new String(ByteStreams.toByteArray(input.content.getInputStream()), UTF_8);
+            new String(ByteStreams.toByteArray(newContent.getInputStream()), UTF_8);
         return editMessage.apply(rsrc, editCommitMessageInput);
       }
+
       if (Strings.isNullOrEmpty(path) || path.charAt(0) == '/') {
         throw new ResourceConflictException("Invalid path: " + path);
       }
 
       try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
-        editModifier.modifyFile(repository, rsrc.getNotes(), path, input.content);
+        editModifier.modifyFile(repository, rsrc.getNotes(), path, newContent);
       } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 64349a4..b717eb7 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -96,6 +96,15 @@
   private static final byte[] CONTENT_NEW = "baz".getBytes(UTF_8);
   private static final String CONTENT_NEW2_STR = "quxÄÜÖßµ";
   private static final byte[] CONTENT_NEW2 = CONTENT_NEW2_STR.getBytes(UTF_8);
+  private static final String CONTENT_BINARY_ENCODED_NEW =
+      "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==";
+  private static final byte[] CONTENT_BINARY_DECODED_NEW = "Hello, World!".getBytes(UTF_8);
+  private static final String CONTENT_BINARY_ENCODED_NEW2 =
+      "data:text/plain;base64,VXBsb2FkaW5nIHRvIGFuIGVkaXQgd29ya2VkIQ==";
+  private static final byte[] CONTENT_BINARY_DECODED_NEW2 =
+      "Uploading to an edit worked!".getBytes(UTF_8);
+  private static final String CONTENT_BINARY_ENCODED_NEW3 =
+      "data:text/plain,VXBsb2FkaW5nIHRvIGFuIGVkaXQgd29ya2VkIQ==";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -316,7 +325,7 @@
         assertThrows(
             BadRequestException.class,
             () -> gApi.changes().id(changeId).edit().modifyFile(Patch.COMMIT_MSG, (RawInput) null));
-    assertThat(ex).hasMessageThat().isEqualTo("new content required");
+    assertThat(ex).hasMessageThat().isEqualTo("either content or binary_content is required");
   }
 
   @Test
@@ -560,11 +569,30 @@
   }
 
   @Test
+  public void createAndUploadBinaryInChangeEditOneRequestRest() throws Exception {
+    FileContentInput in = new FileContentInput();
+    in.binary_content = CONTENT_BINARY_ENCODED_NEW;
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_BINARY_DECODED_NEW);
+    in.binary_content = CONTENT_BINARY_ENCODED_NEW2;
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_BINARY_DECODED_NEW2);
+  }
+
+  @Test
+  public void invalidBase64UploadBinaryInChangeEditOneRequestRest() throws Exception {
+    FileContentInput in = new FileContentInput();
+    in.binary_content = CONTENT_BINARY_ENCODED_NEW3;
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertBadRequest();
+  }
+
+  @Test
   public void changeEditNoContentProvidedRest() throws Exception {
     createEmptyEditFor(changeId);
-    adminRestSession
-        .put(urlEditFile(changeId, FILE_NAME), new FileContentInput())
-        .assertBadRequest();
+
+    FileContentInput in = new FileContentInput();
+    in.binary_content = null;
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertBadRequest();
   }
 
   @Test