diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java index d7c0d3cf096b0216c242c745ad2b815a2a5d1215..b4160c158c97a5af962474e61062e6ef4cbbd945 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java @@ -10,6 +10,7 @@ import it.inaf.ia2.aa.data.User; import it.inaf.ia2.vospace.ui.VOSpaceUiApplication; import it.inaf.ia2.vospace.ui.data.Job; import it.inaf.ia2.vospace.ui.exception.BadRequestException; +import it.inaf.ia2.vospace.ui.exception.VOSpaceStatusException; import it.inaf.ia2.vospace.ui.exception.VOSpaceException; import static it.inaf.oats.vospace.datamodel.NodeUtils.urlEncodePath; import java.io.IOException; @@ -256,7 +257,7 @@ public class VOSpaceClient { return response; } logServerError(request, response); - throw new VOSpaceException("Error calling " + request.uri().toString() + ". Server response code is " + response.statusCode()); + throw new VOSpaceStatusException("Error calling " + request.uri().toString() + ". Server response code is " + response.statusCode(), response.statusCode()); }) .thenApplyAsync(response -> { HttpResponse<T> prev = response.previousResponse().orElse(null); diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/UploadController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/UploadController.java index fb9a422d5222e4a9cd7ed306cdcbe7ad28ea1c13..e3fb16aa4754b8de23964b8b1ae8586f219e4e28 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/UploadController.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/UploadController.java @@ -6,8 +6,11 @@ package it.inaf.ia2.vospace.ui.controller; import it.inaf.ia2.vospace.ui.client.VOSpaceClient; +import it.inaf.ia2.vospace.ui.data.PreUploadResult; import it.inaf.ia2.vospace.ui.data.UploadFilesData; import it.inaf.ia2.vospace.ui.exception.PermissionDeniedException; +import it.inaf.ia2.vospace.ui.exception.VOSpaceException; +import it.inaf.ia2.vospace.ui.exception.VOSpaceStatusException; import static it.inaf.oats.vospace.datamodel.NodeUtils.urlEncodePath; import java.util.Arrays; import java.util.List; @@ -18,6 +21,8 @@ import net.ivoa.xml.vospace.v2.DataNode; import net.ivoa.xml.vospace.v2.Property; import net.ivoa.xml.vospace.v2.Protocol; import net.ivoa.xml.vospace.v2.Transfer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; @@ -29,6 +34,8 @@ import org.springframework.web.bind.annotation.RestController; @RestController public class UploadController extends BaseController { + private static final Logger LOG = LoggerFactory.getLogger(UploadController.class); + @Value("${vospace-authority}") private String authority; @@ -36,17 +43,17 @@ public class UploadController extends BaseController { private VOSpaceClient client; @PostMapping(value = "/preupload", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity<List<String>> prepareForUpload(@RequestBody @Valid UploadFilesData data) { + public ResponseEntity<List<PreUploadResult>> prepareForUpload(@RequestBody @Valid UploadFilesData data) { if (getUser() == null) { throw new PermissionDeniedException("File upload not allowed to anonymous users"); } - CompletableFuture<String>[] calls + CompletableFuture<PreUploadResult>[] calls = data.getFiles().stream().map(fileName -> prepareForDownload(getParentPath(data), fileName)) .toArray(CompletableFuture[]::new); - List<String> uploadUrls = CompletableFuture.allOf(calls) + List<PreUploadResult> uploadUrls = CompletableFuture.allOf(calls) .thenApplyAsync(ignore -> { return Arrays.stream(calls).map(c -> c.join()).collect(Collectors.toList()); }).join(); @@ -62,7 +69,7 @@ public class UploadController extends BaseController { return parentPath; } - public CompletableFuture<String> prepareForDownload(String parentPath, String fileName) { + public CompletableFuture<PreUploadResult> prepareForDownload(String parentPath, String fileName) { return CompletableFuture.supplyAsync(() -> { @@ -75,9 +82,31 @@ public class UploadController extends BaseController { String nodeUri = "vos://" + authority + urlEncodePath(path); - createDataNode(nodeUri, getUser().getName()); + PreUploadResult result = new PreUploadResult(); + + try { + createDataNode(nodeUri, getUser().getName()); + } catch (Throwable t) { + if (t instanceof VOSpaceStatusException && ((VOSpaceStatusException) t).getHttpStatus() == 409) { + result.setError("Node already exists"); + } else { + LOG.error("Error while creating node metadata for " + nodeUri, t); + result.setError("Unable to create node metadata"); + } + return result; + } + + try { + String uploadUrl = obtainUploadUrl(nodeUri); + result.setUrl(uploadUrl); + } catch (VOSpaceException ex) { + result.setError(ex.getMessage()); + } catch (Throwable t) { + LOG.error("Error while obtaining upload URL for " + nodeUri, t); + result.setError("Unable to obtain upload URL"); + } - return obtainUploadUrl(nodeUri); + return result; }, Runnable::run); // Passing current thread Executor to CompletableFuture to avoid "No thread-bound request found" exception } diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/PreUploadResult.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/PreUploadResult.java new file mode 100644 index 0000000000000000000000000000000000000000..47972bbba10e047f64d4cf85a88a29caeba68ee5 --- /dev/null +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/PreUploadResult.java @@ -0,0 +1,35 @@ +/* + * 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; + +/** + * Represents the result of the operations necessary before uploading a file + * (node metadata creation and generation of pushToVoSpace upload URL). If one + * of these operations fails the UI shows the error message for the specific + * file involved in the failure rather than aborting the whole upload (that can + * be composed by multiple files). + */ +public class PreUploadResult { + + private String url; + private String error; + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } +} diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/VOSpaceStatusException.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/VOSpaceStatusException.java new file mode 100644 index 0000000000000000000000000000000000000000..70da49e24c39ad236bfc4bffca9b207e73f567c7 --- /dev/null +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/VOSpaceStatusException.java @@ -0,0 +1,20 @@ +/* + * 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.exception; + +public class VOSpaceStatusException extends VOSpaceException { + + private final int httpStatus; + + public VOSpaceStatusException(String message, int httpStatus) { + super(message); + this.httpStatus = httpStatus; + } + + public int getHttpStatus() { + return httpStatus; + } +} diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/UploadControllerTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/UploadControllerTest.java index 18e8fdc415235326d5d637153daff2c03bb5be90..50e0459f2488dcceb27bff64e4ecb1ec3793a295 100644 --- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/UploadControllerTest.java +++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/UploadControllerTest.java @@ -9,11 +9,15 @@ 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.UploadFilesData; +import it.inaf.ia2.vospace.ui.exception.VOSpaceException; +import it.inaf.ia2.vospace.ui.exception.VOSpaceStatusException; import java.util.List; +import static org.hamcrest.core.IsNull.nullValue; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.mockito.ArgumentMatchers.any; import org.mockito.Mock; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.when; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -63,7 +67,8 @@ public class UploadControllerTest { .content(MAPPER.writeValueAsString(data))) .andDo(print()) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0]").value("http://files/mynode/test.txt")); + .andExpect(jsonPath("$[0].url").value("http://files/mynode/test.txt")) + .andExpect(jsonPath("$[0].error").value(nullValue())); } @Test @@ -81,7 +86,8 @@ public class UploadControllerTest { .content(MAPPER.writeValueAsString(data))) .andDo(print()) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0]").value("http://files/test.txt")); + .andExpect(jsonPath("$[0].url").value("http://files/test.txt")) + .andExpect(jsonPath("$[0].error").value(nullValue())); } @Test @@ -96,4 +102,80 @@ public class UploadControllerTest { .content(MAPPER.writeValueAsString(data))) .andExpect(status().isForbidden()); } + + @Test + public void testDuplicatedNodeError() throws Exception { + + UploadFilesData data = new UploadFilesData(); + data.setParentPath("/mynode"); + data.setFiles(List.of("test.txt")); + + doThrow(new VOSpaceStatusException("Conflict", 409)).when(client).createNode(any()); + + mockMvc.perform(post("/preupload") + .sessionAttr("user_data", user) + .contentType(MediaType.APPLICATION_JSON) + .content(MAPPER.writeValueAsString(data))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].error").value("Node already exists")) + .andExpect(jsonPath("$[0].url").value(nullValue())); + } + + @Test + public void testGenericMetadataCreationError() throws Exception { + + UploadFilesData data = new UploadFilesData(); + data.setParentPath("/mynode"); + data.setFiles(List.of("test.txt")); + + doThrow(new VOSpaceStatusException("Server Error", 500)).when(client).createNode(any()); + + mockMvc.perform(post("/preupload") + .sessionAttr("user_data", user) + .contentType(MediaType.APPLICATION_JSON) + .content(MAPPER.writeValueAsString(data))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].error").value("Unable to create node metadata")) + .andExpect(jsonPath("$[0].url").value(nullValue())); + } + + @Test + public void testUploadUrlRecognizedError() throws Exception { + + UploadFilesData data = new UploadFilesData(); + data.setParentPath("/mynode"); + data.setFiles(List.of("test.txt")); + + doThrow(new VOSpaceException("Unable to connect")).when(client).getFileServiceEndpoint(any()); + + mockMvc.perform(post("/preupload") + .sessionAttr("user_data", user) + .contentType(MediaType.APPLICATION_JSON) + .content(MAPPER.writeValueAsString(data))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].error").value("Unable to connect")) + .andExpect(jsonPath("$[0].url").value(nullValue())); + } + + @Test + public void testUploadUrlFatalError() throws Exception { + + UploadFilesData data = new UploadFilesData(); + data.setParentPath("/mynode"); + data.setFiles(List.of("test.txt")); + + doThrow(new NullPointerException()).when(client).getFileServiceEndpoint(any()); + + mockMvc.perform(post("/preupload") + .sessionAttr("user_data", user) + .contentType(MediaType.APPLICATION_JSON) + .content(MAPPER.writeValueAsString(data))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].error").value("Unable to obtain upload URL")) + .andExpect(jsonPath("$[0].url").value(nullValue())); + } }