Skip to content
Snippets Groups Projects
Commit d93836d2 authored by Sonia Zorba's avatar Sonia Zorba
Browse files

Implemented creation of links (single and by list of links upload)

parent 3aecd050
No related branches found
No related tags found
No related merge requests found
Pipeline #8794 passed
/*
* 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;
}
}
}
/*
* 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;
}
}
/*
* 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"));
}
}
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
...@@ -151,6 +151,38 @@ export default { ...@@ -151,6 +151,38 @@ export default {
source 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) { deleteNodes(paths, calledFromUploadModal = false) {
let url = BASE_API_URL + 'delete'; let url = BASE_API_URL + 'delete';
return apiRequest({ return apiRequest({
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
<div class="mb-3"> <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.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.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 variant="primary" text="Actions" v-if="actionsEnabled">
<b-dropdown-item :disabled="!asyncButtonEnabled" @click="startAsyncRecallJob">Async recall</b-dropdown-item> <b-dropdown-item :disabled="!asyncButtonEnabled" @click="startAsyncRecallJob">Async recall</b-dropdown-item>
<b-dropdown-item :disabled="!deleteButtonEnabled" @click="deleteNodes">Delete</b-dropdown-item> <b-dropdown-item :disabled="!deleteButtonEnabled" @click="deleteNodes">Delete</b-dropdown-item>
...@@ -48,6 +49,7 @@ ...@@ -48,6 +49,7 @@
<RenameModal /> <RenameModal />
<MoveOrCopyModal /> <MoveOrCopyModal />
<ConfirmArchiveModal /> <ConfirmArchiveModal />
<CreateLinksModal />
</div> </div>
</template> </template>
...@@ -60,6 +62,7 @@ import ShareModal from './modal/ShareModal.vue' ...@@ -60,6 +62,7 @@ import ShareModal from './modal/ShareModal.vue'
import RenameModal from './modal/RenameModal.vue' import RenameModal from './modal/RenameModal.vue'
import MoveOrCopyModal from './modal/MoveOrCopyModal.vue' import MoveOrCopyModal from './modal/MoveOrCopyModal.vue'
import ConfirmArchiveModal from './modal/ConfirmArchiveModal.vue' import ConfirmArchiveModal from './modal/ConfirmArchiveModal.vue'
import CreateLinksModal from './modal/CreateLinksModal.vue'
export default { export default {
components: { components: {
...@@ -71,7 +74,8 @@ export default { ...@@ -71,7 +74,8 @@ export default {
ShareModal, ShareModal,
RenameModal, RenameModal,
MoveOrCopyModal, MoveOrCopyModal,
ConfirmArchiveModal ConfirmArchiveModal,
CreateLinksModal
}, },
computed: { computed: {
breadcrumbs() { breadcrumbs() {
......
<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>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment