From d93836d236416fa71ae619633e8a3e93a738178b Mon Sep 17 00:00:00 2001 From: Sonia Zorba <sonia.zorba@inaf.it> Date: Mon, 6 Dec 2021 13:07:44 +0100 Subject: [PATCH] Implemented creation of links (single and by list of links upload) --- .../ui/controller/CreateLinksController.java | 102 ++++++++++++++ .../vospace/ui/data/CreateLinkRequest.java | 37 +++++ .../controller/CreateLinksControllerTest.java | 115 ++++++++++++++++ .../src/test/resources/list-of-links.txt | 75 +++++++++++ vospace-ui-frontend/src/api/server/index.js | 32 +++++ vospace-ui-frontend/src/components/Main.vue | 6 +- .../src/components/modal/CreateLinksModal.vue | 126 ++++++++++++++++++ 7 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/CreateLinksController.java create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/CreateLinkRequest.java create mode 100644 vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/CreateLinksControllerTest.java create mode 100644 vospace-ui-backend/src/test/resources/list-of-links.txt create mode 100644 vospace-ui-frontend/src/components/modal/CreateLinksModal.vue diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/CreateLinksController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/CreateLinksController.java new file mode 100644 index 0000000..8d0ea52 --- /dev/null +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/CreateLinksController.java @@ -0,0 +1,102 @@ +/* + * This file is part of vospace-ui + * Copyright (C) 2021 Istituto Nazionale di Astrofisica + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.inaf.ia2.vospace.ui.controller; + +import it.inaf.ia2.vospace.ui.client.VOSpaceClient; +import it.inaf.ia2.vospace.ui.data.CreateLinkRequest; +import it.inaf.ia2.vospace.ui.exception.BadRequestException; +import it.inaf.ia2.vospace.ui.exception.VOSpaceStatusException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import net.ivoa.xml.vospace.v2.ContainerNode; +import net.ivoa.xml.vospace.v2.LinkNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +public class CreateLinksController extends BaseController { + + private static final Logger LOG = LoggerFactory.getLogger(CreateLinksController.class); + + @Autowired + private VOSpaceClient client; + + @PostMapping(value = "/createLink", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity<?> createLink(@RequestBody CreateLinkRequest request) { + + ContainerNode parent = getFolder(request.getFolder()); + + String uri = parent.getUri() + "/" + request.getNodeName(); + + LinkNode link = new LinkNode(); + link.setUri(uri); + link.setTarget(request.getUrl()); + + client.createNode(link); + + return ResponseEntity.noContent().build(); + } + + @PostMapping(value = "/uploadLinks", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity<?> uploadLinks(@RequestParam(value = "file", required = true) MultipartFile file, + @RequestParam("folder") String folder) throws IOException { + + ContainerNode parent = getFolder(folder); + + String fileContent = new String(file.getBytes()); + + // Execute HTTP calls for links creation in bunches of 20 calls performed in parallel + List<List<CompletableFuture<?>>> httpCallsGroups = new ArrayList<>(); + List<CompletableFuture<?>> currentHttpCallsGroup = new ArrayList<>(); + httpCallsGroups.add(currentHttpCallsGroup); + + for (String url : fileContent.replaceAll("\\r\\n?", "\n").split("\n")) { // normalize newlines and split on them + if (!url.isBlank()) { + + String fileName = url.substring(url.lastIndexOf("/") + 1); + String uri = parent.getUri() + "/" + fileName; + + LinkNode link = new LinkNode(); + link.setUri(uri); + link.setTarget(url); + + if (currentHttpCallsGroup.size() > 20) { + currentHttpCallsGroup = new ArrayList<>(); + httpCallsGroups.add(currentHttpCallsGroup); + } + + currentHttpCallsGroup.add(CompletableFuture.supplyAsync(() -> client.createNode(link), Runnable::run)); + } + } + + for (List<CompletableFuture<?>> httpCallsGroup : httpCallsGroups) { + CompletableFuture.allOf(httpCallsGroup.toArray(CompletableFuture[]::new)).join(); + } + + return ResponseEntity.noContent().build(); + } + + private ContainerNode getFolder(String folderPath) { + try { + return (ContainerNode) client.getNode("/" + folderPath); + } catch (VOSpaceStatusException ex) { + if (ex.getHttpStatus() == 404) { + throw new BadRequestException("Folder parameter specified a non-existent folder: /" + folderPath); + } + throw ex; + } + } +} diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/CreateLinkRequest.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/CreateLinkRequest.java new file mode 100644 index 0000000..74d9e72 --- /dev/null +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/CreateLinkRequest.java @@ -0,0 +1,37 @@ +/* + * This file is part of vospace-ui + * Copyright (C) 2021 Istituto Nazionale di Astrofisica + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.inaf.ia2.vospace.ui.data; + +public class CreateLinkRequest { + + private String url; + private String folder; + private String nodeName; + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getFolder() { + return folder; + } + + public void setFolder(String folder) { + this.folder = folder; + } + + public String getNodeName() { + return nodeName; + } + + public void setNodeName(String nodeName) { + this.nodeName = nodeName; + } +} diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/CreateLinksControllerTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/CreateLinksControllerTest.java new file mode 100644 index 0000000..f1eb4d7 --- /dev/null +++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/CreateLinksControllerTest.java @@ -0,0 +1,115 @@ +/* + * This file is part of vospace-ui + * Copyright (C) 2021 Istituto Nazionale di Astrofisica + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.inaf.ia2.vospace.ui.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import it.inaf.ia2.aa.data.User; +import it.inaf.ia2.vospace.ui.client.VOSpaceClient; +import it.inaf.ia2.vospace.ui.data.CreateLinkRequest; +import it.inaf.ia2.vospace.ui.exception.VOSpaceStatusException; +import net.ivoa.xml.vospace.v2.ContainerNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import org.mockito.Mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource(properties = {"vospace-authority=example.com!vospace"}) +public class CreateLinksControllerTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @MockBean + private VOSpaceClient client; + + @Autowired + private MockMvc mockMvc; + + @Mock + private User user; + + @BeforeEach + public void setUp() { + when(user.getName()).thenReturn("user_id"); + } + + @Test + public void testCreateSingleLink() throws Exception { + + ContainerNode myFolder = new ContainerNode(); + myFolder.setUri("vos://ia2.inaf.it!vospace/path/to/myfolder"); + when(client.getNode("/path/to/myfolder")).thenReturn(myFolder); + + CreateLinkRequest request = new CreateLinkRequest(); + + request.setFolder("path/to/myfolder"); + request.setUrl("http://archives.ia2.inaf.it/files/aao/SC182172.fits.gz"); + request.setNodeName("myLink"); + + String requestPayload = MAPPER.writeValueAsString(request); + + mockMvc.perform(post("/createLink") + .contentType(MediaType.APPLICATION_JSON) + .content(requestPayload) + .sessionAttr("user_data", user)) + .andDo(print()) + .andExpect(status().isNoContent()); + + verify(client, times(1)).createNode(argThat(node -> { + return node.getUri().equals("vos://ia2.inaf.it!vospace/path/to/myfolder/myLink"); + })); + } + + @Test + public void testUploadLinksNonExistentFolder() throws Exception { + + when(client.getNode("/path/to/non-existent")).thenThrow(new VOSpaceStatusException("Not found", 404)); + + mockMvc.perform(multipart("/uploadLinks") + .file(getListOfLinksMockMultipartFile()) + .param("folder", "path/to/non-existent") + .sessionAttr("user_data", user)) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + public void testUploadLinks() throws Exception { + + ContainerNode myFolder = new ContainerNode(); + when(client.getNode("/path/to/myfolder")).thenReturn(myFolder); + + mockMvc.perform(multipart("/uploadLinks") + .file(getListOfLinksMockMultipartFile()) + .param("folder", "path/to/myfolder") + .sessionAttr("user_data", user)) + .andDo(print()) + .andExpect(status().isNoContent()); + + verify(client, times(75)).createNode(any()); + } + + private MockMultipartFile getListOfLinksMockMultipartFile() throws Exception { + return new MockMultipartFile("file", UploadControllerTest.class.getClassLoader().getResourceAsStream("list-of-links.txt")); + } +} diff --git a/vospace-ui-backend/src/test/resources/list-of-links.txt b/vospace-ui-backend/src/test/resources/list-of-links.txt new file mode 100644 index 0000000..0474fce --- /dev/null +++ b/vospace-ui-backend/src/test/resources/list-of-links.txt @@ -0,0 +1,75 @@ +http://archives.ia2.inaf.it/files/aao/SC182159.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182160.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182161.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182169.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182170.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182171.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182172.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182173.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182174.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182175.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182176.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182177.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182178.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182338.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182339.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182340.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182341.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182342.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182343.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182344.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182345.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182346.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182347.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182348.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182349.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182350.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182351.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182352.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182353.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182354.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182355.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182356.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182162.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182163.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182164.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182165.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182166.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182167.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182168.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182357.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182358.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182359.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182360.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182361.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182362.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182363.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182364.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182365.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182366.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182367.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182368.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182369.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182370.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182371.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182372.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182373.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182374.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182375.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182376.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182377.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182378.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182379.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182380.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182381.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182382.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182386.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182383.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182384.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182385.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182387.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182388.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182389.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182390.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182391.fits.gz +http://archives.ia2.inaf.it/files/aao/SC182392.fits.gz diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js index fb22961..1d63162 100644 --- a/vospace-ui-frontend/src/api/server/index.js +++ b/vospace-ui-frontend/src/api/server/index.js @@ -151,6 +151,38 @@ export default { source }; }, + createLink(nodeUrl, folder, nodeName) { + let url = BASE_API_URL + 'createLink'; + return apiRequest({ + method: 'POST', + url: url, + withCredentials: true, + headers: { + 'Cache-Control': 'no-cache' + }, + data: { + url: nodeUrl, + folder, + nodeName + } + }); + }, + uploadLinks(file, path) { + let formData = new FormData(); + formData.append('file', file); + + let url = BASE_API_URL + 'uploadLinks?folder=' + escapePath(path); + return apiRequest({ + method: 'POST', + url: url, + withCredentials: true, + headers: { + 'Cache-Control': 'no-cache', + 'Content-Type': 'multipart/form-data' + }, + data: formData + }); + }, deleteNodes(paths, calledFromUploadModal = false) { let url = BASE_API_URL + 'delete'; return apiRequest({ diff --git a/vospace-ui-frontend/src/components/Main.vue b/vospace-ui-frontend/src/components/Main.vue index 9649232..163639e 100644 --- a/vospace-ui-frontend/src/components/Main.vue +++ b/vospace-ui-frontend/src/components/Main.vue @@ -9,6 +9,7 @@ <div class="mb-3"> <b-button variant="success" class="mr-2" :disabled="!writable" v-b-modal.create-folder-modal>New folder</b-button> <b-button variant="success" class="mr-2" :disabled="!writable" v-b-modal.upload-files-modal>Upload files</b-button> + <b-button variant="success" class="mr-2" :disabled="!writable" v-b-modal.create-links-modal>Create links</b-button> <b-dropdown variant="primary" text="Actions" v-if="actionsEnabled"> <b-dropdown-item :disabled="!asyncButtonEnabled" @click="startAsyncRecallJob">Async recall</b-dropdown-item> <b-dropdown-item :disabled="!deleteButtonEnabled" @click="deleteNodes">Delete</b-dropdown-item> @@ -48,6 +49,7 @@ <RenameModal /> <MoveOrCopyModal /> <ConfirmArchiveModal /> + <CreateLinksModal /> </div> </template> @@ -60,6 +62,7 @@ import ShareModal from './modal/ShareModal.vue' import RenameModal from './modal/RenameModal.vue' import MoveOrCopyModal from './modal/MoveOrCopyModal.vue' import ConfirmArchiveModal from './modal/ConfirmArchiveModal.vue' +import CreateLinksModal from './modal/CreateLinksModal.vue' export default { components: { @@ -71,7 +74,8 @@ export default { ShareModal, RenameModal, MoveOrCopyModal, - ConfirmArchiveModal + ConfirmArchiveModal, + CreateLinksModal }, computed: { breadcrumbs() { diff --git a/vospace-ui-frontend/src/components/modal/CreateLinksModal.vue b/vospace-ui-frontend/src/components/modal/CreateLinksModal.vue new file mode 100644 index 0000000..ec1f540 --- /dev/null +++ b/vospace-ui-frontend/src/components/modal/CreateLinksModal.vue @@ -0,0 +1,126 @@ +<template> +<b-modal id="create-links-modal" title="Create links" okTitle="Create" @show="reset" @ok.prevent="createLinks"> + <div class="row"> + <div class="col-2"> + <label for="creationMode">Mode:</label> + </div> + <div class="col-10"> + <b-form-group v-slot="{ creationMode }" id="creationMode"> + <b-form-radio-group id="creation-mode-group" v-model="mode" :aria-describedby="creationMode" name="creation-mode"> + <b-form-radio value="single">single</b-form-radio> + <b-form-radio value="multiple">multiple (upload list)</b-form-radio> + </b-form-radio-group> + </b-form-group> + </div> + </div> + <b-form inline v-if="mode === 'single'"> + <label class="w-25" for="url-input">URL</label> + <b-form-input v-model.trim="url" id="url-input" class="w-75" aria-describedby="url-input-feedback" :state="urlState" v-on:input="resetErrors"> + </b-form-input> + <b-form-invalid-feedback id="url-input-feedback" class="text-right">{{urlError}}</b-form-invalid-feedback> + <label class="w-25 mt-2" for="node-name-input">Node name</label> + <b-form-input v-model.trim="nodeName" id="node-name-input" class="w-75 mt-2" aria-describedby="node-name-input-feedback" :state="nodeNameState" v-on:input="resetErrors"> + </b-form-input> + <b-form-invalid-feedback id="node-name-input-feedback" class="text-right">{{nodeNameError}}</b-form-invalid-feedback> + </b-form> + <b-form inline v-if="mode === 'multiple'"> + <p>Upload list of links (separated by newlines)</p> + <b-form-file class="text-left" v-model="file" :multiple="false" placeholder="Choose your file or drop it here..." drop-placeholder="Drop file here..." :state="fileState"></b-form-file> + <b-form-invalid-feedback id="file-input-feedback" class="text-right">{{fileError}}</b-form-invalid-feedback> + </b-form> +</b-modal> +</template> + +<script> +import client from 'api-client' + +export default { + data() { + return { + mode: 'single', + url: null, + nodeName: null, + urlError: null, + nodeNameError: null, + file: null, + fileError: null + }; + }, + computed: { + urlState() { + if (this.urlError) { + return false; + } + return null; + }, + nodeNameState() { + if (this.nodeNameError) { + return false; + } + return null; + }, + fileState() { + if (this.fileError) { + return false; + } + return null; + } + }, + methods: { + reset() { + this.mode = 'single'; + this.url = null; + this.nodeName = null; + this.file = null; + this.resetErrors(); + }, + resetErrors() { + this.urlError = null; + this.nodeNameError = null; + this.fileError = null; + }, + createLinks() { + let path = this.$store.state.path; + + if (this.mode === 'single') { + if (!this.url) { + this.urlError = "URL is required"; + return; + } + try { + new URL(this.url); + } catch (_) { + this.urlError = "Invalid URL"; + return; + } + + if (!this.nodeName) { + this.nodeNameError = "Node name is required"; + return; + } else if (/[<>?":\\/`|'*]/.test(this.nodeName)) { + this.nodeNameError = "Node name contains an illegal character. Following characters are not allowed: < > ? \" : \\ / | ' * `"; + return; + } + + client.createLink(this.url, path, this.nodeName).then(() => { + this.$bvModal.hide('create-links-modal'); + // Reload current node + this.$store.dispatch('setPath', path); + }); + + } else { + if (!this.file) { + this.fileError = "File is required"; + return; + } + + client.uploadLinks(this.file, path).then(() => { + this.$bvModal.hide('create-links-modal'); + // Reload current node + this.$store.dispatch('setPath', path); + }); + } + } + } +} +</script> -- GitLab