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 <guanhua...@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;