Commit 4cad3312 authored by Sonia Zorba's avatar Sonia Zorba
Browse files

Uploads improvements: supported partial upload failure and handled errors for edge cases

parent d03bdbf9
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -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);
+35 −6
Original line number Diff line number Diff line
@@ -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);

            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
    }

+35 −0
Original line number Diff line number Diff line
/*
 * 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;
    }
}
+20 −0
Original line number Diff line number Diff line
/*
 * 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;
    }
}
+84 −2
Original line number Diff line number Diff line
@@ -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()));
    }
}