You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@zeppelin.apache.org by zj...@apache.org on 2022/03/14 08:10:10 UTC

[zeppelin] branch master updated: [ZEPPELIN-5663] Provide REST API about notebook version control (#4300)

This is an automated email from the ASF dual-hosted git repository.

zjffdu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new 60ddde6  [ZEPPELIN-5663] Provide REST API about notebook version control (#4300)
60ddde6 is described below

commit 60ddde694681f2071e60b21dfd600c4831b0aad4
Author: nihua <gu...@foxmail.com>
AuthorDate: Mon Mar 14 16:09:56 2022 +0800

    [ZEPPELIN-5663] Provide REST API about notebook version control (#4300)
    
    * [ZEPPELIN-5663] Provide REST API about notebook version control
    
    * change RESTFUL API method checkpointNote(...) to return revisionId like that addParagraph return paragraphId
---
 docs/usage/rest_api/notebook.md                    | 238 +++++++++++++++++++++
 .../org/apache/zeppelin/rest/NotebookRestApi.java  |  88 +++++++-
 .../rest/message/CheckpointNoteRequest.java        |  28 +++
 .../apache/zeppelin/service/NotebookService.java   |  10 +-
 .../apache/zeppelin/rest/NotebookRestApiTest.java  | 200 +++++++++++++++++
 5 files changed, 559 insertions(+), 5 deletions(-)

diff --git a/docs/usage/rest_api/notebook.md b/docs/usage/rest_api/notebook.md
index b0dcd46..c49d805 100644
--- a/docs/usage/rest_api/notebook.md
+++ b/docs/usage/rest_api/notebook.md
@@ -1574,3 +1574,241 @@ Notebooks REST API supports the following operations: List, Create, Get, Delete,
   </table>
 
 
+## Version control
+
+
+
+### Get revisions of a note
+
+  <table class="table-configuration">
+    <col width="200">
+    <tr>
+      <td>Description</td>
+      <td>This ```GET``` method gets the revisions of a note.
+      </td>
+    </tr>
+    <tr>
+      <td>URL</td>
+      <td>```http://[zeppelin-server]:[zeppelin-port]/api/notebook/[noteId]/revision```</td>
+    </tr>
+    <tr>
+      <td>Success code</td>
+      <td>200</td>
+    </tr>
+    <tr>
+      <td>Fail code</td>
+      <td>500</td>
+    </tr>
+    <tr>
+      <td> sample JSON response </td>
+      <td>
+
+```json
+{
+    "status": "OK",
+    "body": [
+        {
+            "id": "f97ce5c7f076783023d33623ad52ca994277e5c1",
+            "message": "first commit",
+            "time": 1645712061
+        },
+        {
+            "id": "e9b964bebdecec6a59efe085f97db4040ae333aa",
+            "message": "second commit",
+            "time": 1645693163
+        }
+    ]
+}
+```
+</td>
+    </tr>
+  </table>
+
+<br/>
+### Save a revision for a note
+  <table class="table-configuration">
+    <col width="200">
+    <tr>
+      <td>Description</td>
+      <td>This ```POST``` method saves a revision for a note.
+      </td>
+    </tr>
+    <tr>
+      <td>URL</td>
+      <td>```http://[zeppelin-server]:[zeppelin-port]/api/notebook/[noteId]/revision```</td>
+    </tr>
+    <tr>
+      <td>Success code</td>
+      <td>200</td>
+    </tr>
+    <tr>
+      <td>Bad Request code</td>
+      <td>400</td>
+    </tr>
+    <tr>
+      <td>Fail code</td>
+      <td>500</td>
+    </tr>
+    <tr>
+      <td> sample JSON input </td>
+      <td>
+
+```json
+{
+  "commitMessage": "first commit"
+}
+```
+</td>
+    </tr>
+    <tr>
+      <td> sample JSON response </td>
+      <td>
+
+```json
+{
+  "status": "OK",
+  "message": "",
+  "body": "6a5879218dfb797b013bcd24a594808045e34875"
+}
+```
+</td>
+    </tr>
+  </table>
+### Get a revision of a note
+  <table class="table-configuration">
+    <col width="200">
+    <tr>
+      <td>Description</td>
+      <td>This ```GET``` method gets a revision of a note.
+      </td>
+    </tr>
+    <tr>
+      <td>URL</td>
+      <td>```http://[zeppelin-server]:[zeppelin-port]/api/notebook/[noteId]/revision/{revisionId}```</td>
+    </tr>
+    <tr>
+      <td>Success code</td>
+      <td>200</td>
+    </tr>
+    <tr>
+      <td>Fail code</td>
+      <td>500</td>
+    </tr>
+    <tr>
+      <td> sample JSON response </td>
+      <td>
+
+```json
+{
+  "status": "OK",
+  "message": "",
+  "body": {
+    "paragraphs": [
+      {
+        "text": "%sql \nselect age, count(1) value\nfrom bank \nwhere age < 30 \ngroup by
+ age \norder by age",
+        "config": {
+          "colWidth": 4,
+          "graph": {
+            "mode": "multiBarChart",
+            "height": 300,
+            "optionOpen": false,
+            "keys": [
+              {
+                "name": "age",
+                "index": 0,
+                "aggr": "sum"
+              }
+            ],
+            "values": [
+              {
+                "name": "value",
+                "index": 1,
+                "aggr": "sum"
+              }
+            ],
+            "groups": [],
+            "scatter": {
+              "xAxis": {
+                "name": "age",
+                "index": 0,
+                "aggr": "sum"
+              },
+              "yAxis": {
+                "name": "value",
+                "index": 1,
+                "aggr": "sum"
+              }
+            }
+          }
+        },
+        "settings": {
+          "params": {},
+          "forms": {}
+        },
+        "jobName": "paragraph\_1423500782552\_-1439281894",
+        "id": "20150210-015302\_1492795503",
+        "results": {
+          "code": "SUCCESS",
+          "msg": [
+            {
+              "type": "TABLE",
+              "data": "age\tvalue\n19\t4\n20\t3\n21\t7\n22\t9\n23\t20\n24\t24\n25\t44\n26
+\t77\n27\t94\n28\t103\n29\t97\n"
+            }
+          ]
+        },
+        "dateCreated": "Feb 10, 2015 1:53:02 AM",
+        "dateStarted": "Jul 3, 2015 1:43:17 PM",
+        "dateFinished": "Jul 3, 2015 1:43:23 PM",
+        "status": "FINISHED",
+        "progressUpdateIntervalMs": 500
+      }
+    ],
+    "name": "Zeppelin Tutorial",
+    "id": "2A94M5J1Z",
+    "angularObjects": {},
+    "config": {
+      "looknfeel": "default"
+    },
+    "info": {}
+  }
+}
+```
+</td>
+    </tr>
+  </table>
+### Revert a note to a specified version
+  <table class="table-configuration">
+    <col width="200">
+    <tr>
+      <td>Description</td>
+      <td>This ```PUT``` method reverts a note to a specified version
+      </td>
+    </tr>
+    <tr>
+      <td>URL</td>
+      <td>```http://[zeppelin-server]:[zeppelin-port]/api/notebook/[noteId]/revision/{revisionId}```</td>
+    </tr>
+    <tr>
+      <td>Success code</td>
+      <td>200</td>
+    </tr>
+    <tr>
+      <td>Fail code</td>
+      <td>500</td>
+    </tr>
+    <tr>
+      <td> sample JSON response </td>
+      <td>
+
+```json
+{
+  "status": "OK"
+}
+```
+</td>
+    </tr>
+  </table>
+
+
diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/NotebookRestApi.java b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/NotebookRestApi.java
index d68a68a..40ad46a 100644
--- a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/NotebookRestApi.java
+++ b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/NotebookRestApi.java
@@ -48,6 +48,7 @@ import org.apache.zeppelin.notebook.NoteInfo;
 import org.apache.zeppelin.notebook.Notebook;
 import org.apache.zeppelin.notebook.Paragraph;
 import org.apache.zeppelin.notebook.AuthorizationService;
+import org.apache.zeppelin.notebook.repo.NotebookRepoWithVersionControl;
 import org.apache.zeppelin.notebook.scheduler.SchedulerService;
 import org.apache.zeppelin.rest.exception.BadRequestException;
 import org.apache.zeppelin.rest.exception.ForbiddenException;
@@ -334,9 +335,94 @@ public class NotebookRestApi extends AbstractRestApi {
 
 
   /**
+   * Get revision history of a note.
+   *
+   * @param noteId
+   * @return
+   * @throws IOException
+   */
+  @GET
+  @Path("{noteId}/revision")
+  @ZeppelinApi
+  public Response getNoteRevisionHistory(@PathParam("noteId") String noteId) throws IOException {
+    LOGGER.info("Get revision history of note {}", noteId);
+    List<NotebookRepoWithVersionControl.Revision> revisions = notebookService.listRevisionHistory(noteId, getServiceContext(), new RestServiceCallback<>());
+    return new JsonResponse<>(Status.OK, revisions).build();
+  }
+
+
+  /**
+   * Save a revision for the a note
+   * 
+   * @param message
+   * @param noteId
+   * @return
+   * @throws IOException
+   */
+  @POST
+  @Path("{noteId}/revision")
+  @ZeppelinApi
+  public Response checkpointNote(String message,
+                                 @PathParam("noteId") String noteId) throws IOException {
+    LOGGER.info("Commit note by JSON {}", message);
+    CheckpointNoteRequest request = GSON.fromJson(message, CheckpointNoteRequest.class);
+    if (request == null || StringUtils.isEmpty(request.getCommitMessage())) {
+      LOGGER.warn("Trying to commit notebook {} with empty commitMessage", noteId);
+      throw new BadRequestException("commitMessage can not be empty");
+    }
+    NotebookRepoWithVersionControl.Revision revision = notebookService.checkpointNote(noteId, request.getCommitMessage(), getServiceContext(), new RestServiceCallback<>());
+    if (revision == null || StringUtils.isEmpty(revision.id)) {
+      return new JsonResponse<>(Status.OK, "Couldn't checkpoint note revision: possibly no changes found or storage doesn't support versioning. "
+              + "Please check the logs for more details.").build();
+    }
+    return new JsonResponse<>(Status.OK, "", revision.id).build();
+  }
+
+
+  /**
+   * Get a specified revision of a note.
+   *
+   * @param noteId
+   * @param revisionId
+   * @param reload
+   * @return
+   * @throws IOException
+   */
+  @GET
+  @Path("{noteId}/revision/{revisionId}")
+  @ZeppelinApi
+  public Response getNoteByRevison(@PathParam("noteId") String noteId,
+                                   @PathParam("revisionId") String revisionId,
+                                   @QueryParam("reload") boolean reload) throws IOException {
+    LOGGER.info("Get note {} by the revision {}", noteId, revisionId);
+    Note noteRevision = notebookService.getNotebyRevision(noteId, revisionId, getServiceContext(), new RestServiceCallback<>());
+    return new JsonResponse<>(Status.OK, "", noteRevision).build();
+  }
+
+
+  /**
+   * Revert a note to the specified version
+   *
+   * @param noteId
+   * @param revisionId
+   * @return
+   * @throws IOException
+   */
+  @PUT
+  @Path("{noteId}/revision/{revisionId}")
+  @ZeppelinApi
+  public Response setNoteRevision(@PathParam("noteId") String noteId,
+                                  @PathParam("revisionId") String revisionId) throws IOException {
+    LOGGER.info("Revert note {} to the revision {}", noteId, revisionId);
+    notebookService.setNoteRevision(noteId, revisionId, getServiceContext(), new RestServiceCallback<>());
+    return new JsonResponse<>(Status.OK).build();
+  }
+
+
+  /**
    * Get note of this specified notePath.
    *
-   *  @param message - JSON containing notePath
+   * @param message - JSON containing notePath
    * @param reload
    * @return
    * @throws IOException
diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/message/CheckpointNoteRequest.java b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/message/CheckpointNoteRequest.java
new file mode 100644
index 0000000..e47d07a
--- /dev/null
+++ b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/message/CheckpointNoteRequest.java
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.zeppelin.rest.message;
+
+public class CheckpointNoteRequest {
+  String commitMessage;
+
+  public CheckpointNoteRequest() {
+  }
+
+  public String getCommitMessage() {
+    return commitMessage;
+  }
+}
diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/service/NotebookService.java b/zeppelin-server/src/main/java/org/apache/zeppelin/service/NotebookService.java
index fff9a68..02a4bbd 100644
--- a/zeppelin-server/src/main/java/org/apache/zeppelin/service/NotebookService.java
+++ b/zeppelin-server/src/main/java/org/apache/zeppelin/service/NotebookService.java
@@ -371,7 +371,7 @@ public class NotebookService {
   /**
    * Executes given paragraph with passed paragraph info like noteId, paragraphId, title, text and etc.
    *
-   * @param noteId
+   * @param note
    * @param paragraphId
    * @param title
    * @param text
@@ -1012,12 +1012,14 @@ public class NotebookService {
 
   }
 
-  public void getNotebyRevision(String noteId,
+  // notebook.getNoteByRevision(...) does not use the NoteCache,
+  // so we can return a Note object here.
+  public Note getNotebyRevision(String noteId,
                                 String revisionId,
                                 ServiceContext context,
                                 ServiceCallback<Note> callback) throws IOException {
 
-    notebook.processNote(noteId ,
+    return notebook.processNote(noteId,
       note -> {
         if (note == null) {
           callback.onFailure(new NoteNotFoundException(noteId), context);
@@ -1031,7 +1033,7 @@ public class NotebookService {
         Note revisionNote = notebook.getNoteByRevision(noteId, note.getPath(), revisionId,
             context.getAutheInfo());
         callback.onSuccess(revisionNote, context);
-        return null;
+        return revisionNote;
       });
   }
 
diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/rest/NotebookRestApiTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/rest/NotebookRestApiTest.java
index dc95df5..62398c0 100644
--- a/zeppelin-server/src/test/java/org/apache/zeppelin/rest/NotebookRestApiTest.java
+++ b/zeppelin-server/src/test/java/org/apache/zeppelin/rest/NotebookRestApiTest.java
@@ -30,6 +30,7 @@ import com.google.gson.reflect.TypeToken;
 import org.apache.zeppelin.interpreter.InterpreterSetting;
 import org.apache.zeppelin.interpreter.InterpreterSettingManager;
 import org.apache.zeppelin.notebook.Notebook;
+import org.apache.zeppelin.notebook.repo.NotebookRepoWithVersionControl;
 import org.apache.zeppelin.rest.message.ParametersRequest;
 import org.apache.zeppelin.socket.NotebookServer;
 import org.apache.zeppelin.utils.TestUtils;
@@ -165,6 +166,112 @@ public class NotebookRestApiTest extends AbstractTestRestApi {
   }
 
   @Test
+  public void testGetNoteRevisionHistory() throws IOException {
+    LOG.info("Running testGetNoteRevisionHistory");
+    String note1Id = null;
+    Notebook notebook = TestUtils.getInstance(Notebook.class);
+    try {
+      String notePath = "note1";
+      note1Id = notebook.createNote(notePath, anonymous);
+
+      //Add a paragraph and commit
+      NotebookRepoWithVersionControl.Revision first_commit =
+              notebook.processNote(note1Id, note -> {
+                Paragraph p1 = note.addNewParagraph(anonymous);
+                p1.setText("text1");
+                notebook.saveNote(note, AuthenticationInfo.ANONYMOUS);
+                return notebook.checkpointNote(note.getId(), note.getPath(), "first commit", anonymous);
+              });
+
+      //Add a paragraph again
+      notebook.processNote(note1Id, note -> {
+        Paragraph p2 = note.addNewParagraph(anonymous);
+        p2.setText("text2");
+        notebook.saveNote(note, AuthenticationInfo.ANONYMOUS);
+        return null;
+      });
+
+      // Verify
+      CloseableHttpResponse get1 = httpGet("/notebook/" + note1Id + "/revision");
+
+      assertThat(get1, isAllowed());
+      Map<String, Object> resp = gson.fromJson(EntityUtils.toString(get1.getEntity(), StandardCharsets.UTF_8),
+              new TypeToken<Map<String, Object>>() {
+              }.getType());
+      List<Map<String, Object>> body = (List<Map<String, Object>>) resp.get("body");
+      assertEquals(1, body.size());
+      assertEquals(first_commit.id, body.get(0).get("id"));
+      get1.close();
+
+      // Second commit
+      NotebookRepoWithVersionControl.Revision second_commit = notebook.processNote(note1Id, note -> notebook.checkpointNote(note.getId(), note.getPath(), "Second commit", anonymous));
+
+      // Verify
+      CloseableHttpResponse get2 = httpGet("/notebook/" + note1Id + "/revision");
+
+      assertThat(get2, isAllowed());
+      resp = gson.fromJson(EntityUtils.toString(get2.getEntity(), StandardCharsets.UTF_8),
+              new TypeToken<Map<String, Object>>() {
+              }.getType());
+      body = (List<Map<String, Object>>) resp.get("body");
+      assertEquals(2, body.size());
+      assertEquals(second_commit.id, body.get(0).get("id"));
+      get2.close();
+
+    } finally {
+      // cleanup
+      if (null != note1Id) {
+        notebook.removeNote(note1Id, anonymous);
+      }
+    }
+  }
+
+  @Test
+  public void testGetNoteByRevision() throws IOException {
+    LOG.info("Running testGetNoteByRevision");
+    String note1Id = null;
+    Notebook notebook = TestUtils.getInstance(Notebook.class);
+    try {
+      String notePath = "note1";
+      note1Id = notebook.createNote(notePath, anonymous);
+
+      //Add a paragraph and commit
+      NotebookRepoWithVersionControl.Revision first_commit =
+              notebook.processNote(note1Id, note -> {
+                Paragraph p1 = note.addNewParagraph(anonymous);
+                p1.setText("text1");
+                notebook.saveNote(note, AuthenticationInfo.ANONYMOUS);
+                return notebook.checkpointNote(note.getId(), note.getPath(), "first commit", anonymous);
+              });
+
+      //Add a paragraph again
+      notebook.processNote(note1Id, note -> {
+        Paragraph p2 = note.addNewParagraph(anonymous);
+        p2.setText("text2");
+        notebook.saveNote(note, AuthenticationInfo.ANONYMOUS);
+        return null;
+      });
+
+      // Verify
+      CloseableHttpResponse get = httpGet("/notebook/" + note1Id + "/revision/" + first_commit.id);
+
+      assertThat(get, isAllowed());
+      Map<String, Object> resp = gson.fromJson(EntityUtils.toString(get.getEntity(), StandardCharsets.UTF_8),
+              new TypeToken<Map<String, Object>>() {
+              }.getType());
+      Map<String, Object> noteObject = (Map<String, Object>) resp.get("body");
+      assertEquals(1, ((List) noteObject.get("paragraphs")).size());
+      assertEquals("text1", ((List<Map<String, String>>) noteObject.get("paragraphs")).get(0).get("text"));
+      get.close();
+    } finally {
+      // cleanup
+      if (null != note1Id) {
+        notebook.removeNote(note1Id, anonymous);
+      }
+    }
+  }
+
+  @Test
   public void testGetNoteParagraphJobStatus() throws IOException {
     LOG.info("Running testGetNoteParagraphJobStatus");
     String note1Id = null;
@@ -194,6 +301,99 @@ public class NotebookRestApiTest extends AbstractTestRestApi {
   }
 
   @Test
+  public void testCheckpointNote() throws IOException {
+    LOG.info("Running testCheckpointNote");
+    String note1Id = null;
+    Notebook notebook = TestUtils.getInstance(Notebook.class);
+    try {
+      String notePath = "note1";
+      note1Id = notebook.createNote(notePath, anonymous);
+
+      //Add a paragraph
+      notebook.processNote(note1Id, note -> {
+        Paragraph p1 = note.addNewParagraph(anonymous);
+        p1.setText("text1");
+        notebook.saveNote(note, AuthenticationInfo.ANONYMOUS);
+        return null;
+      });
+
+      // Call restful api to save a revision and verify
+      String commitMessage = "first commit";
+      CloseableHttpResponse post = httpPost("/notebook/" + note1Id + "/revision", "{\"commitMessage\" : \"" + commitMessage + "\"}");
+
+      assertThat(post, isAllowed());
+      Map<String, Object> resp = gson.fromJson(EntityUtils.toString(post.getEntity(), StandardCharsets.UTF_8),
+              new TypeToken<Map<String, Object>>() {
+              }.getType());
+      assertEquals("OK", resp.get("status"));
+      String revisionId = (String) resp.get("body");
+      notebook.processNote(note1Id, note -> {
+        Note revisionOfNote = notebook.getNoteByRevision(note.getId(), note.getPath(), revisionId, anonymous);
+        assertEquals(1, notebook.listRevisionHistory(note.getId(), note.getPath(), anonymous).size());
+        assertEquals(1, revisionOfNote.getParagraphs().size());
+        assertEquals("text1", revisionOfNote.getParagraph(0).getText());
+        return null;
+      });
+      post.close();
+    } finally {
+      // cleanup
+      if (null != note1Id) {
+        notebook.removeNote(note1Id, anonymous);
+      }
+    }
+  }
+
+
+  @Test
+  public void testSetNoteRevision() throws IOException {
+    LOG.info("Running testSetNoteRevision");
+    String note1Id = null;
+    Notebook notebook = TestUtils.getInstance(Notebook.class);
+    try {
+      String notePath = "note1";
+      note1Id = notebook.createNote(notePath, anonymous);
+
+      //Add a paragraph and commit
+      NotebookRepoWithVersionControl.Revision first_commit =
+              notebook.processNote(note1Id, note -> {
+                Paragraph p1 = note.addNewParagraph(anonymous);
+                p1.setText("text1");
+                notebook.saveNote(note, AuthenticationInfo.ANONYMOUS);
+                return notebook.checkpointNote(note.getId(), note.getPath(), "first commit", anonymous);
+              });
+
+      //Add a paragraph again
+      notebook.processNote(note1Id, note -> {
+        Paragraph p2 = note.addNewParagraph(anonymous);
+        p2.setText("text2");
+        notebook.saveNote(note, AuthenticationInfo.ANONYMOUS);
+        return null;
+      });
+
+      // Call restful api to revert note to first revision and verify
+      CloseableHttpResponse put = httpPut("/notebook/" + note1Id + "/revision/" + first_commit.id, "");
+
+      assertThat(put, isAllowed());
+      Map<String, Object> resp = gson.fromJson(EntityUtils.toString(put.getEntity(), StandardCharsets.UTF_8),
+              new TypeToken<Map<String, Object>>() {
+              }.getType());
+      assertEquals("OK", resp.get("status"));
+      notebook.processNote(note1Id, note -> {
+        assertEquals(1, note.getParagraphs().size());
+        assertEquals("text1", note.getParagraph(0).getText());
+        return null;
+      });
+      put.close();
+    } finally {
+      // cleanup
+      if (null != note1Id) {
+        notebook.removeNote(note1Id, anonymous);
+      }
+    }
+  }
+
+
+  @Test
   public void testRunParagraphJob() throws Exception {
     LOG.info("Running testRunParagraphJob");
     String note1Id = null;