diff --git a/src/main/java/tools/vitruv/methodologist/setup/config/GlobalExceptionHandler.java b/src/main/java/tools/vitruv/methodologist/setup/config/GlobalExceptionHandler.java index ca097db..beb1d9c 100644 --- a/src/main/java/tools/vitruv/methodologist/setup/config/GlobalExceptionHandler.java +++ b/src/main/java/tools/vitruv/methodologist/setup/config/GlobalExceptionHandler.java @@ -1,13 +1,14 @@ package tools.vitruv.methodologist.setup.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import java.nio.file.NoSuchFileException; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.context.request.WebRequest; import tools.vitruv.methodologist.setup.exception.ErrorResponseDTO; import tools.vitruv.methodologist.setup.exception.GenmodelException; import tools.vitruv.methodologist.setup.exception.MethodologistSetupException; @@ -19,31 +20,23 @@ */ @Slf4j @RestControllerAdvice(basePackages = "tools.vitruv.methodologist.setup") -@RequiredArgsConstructor public class GlobalExceptionHandler { + private final ObjectMapper objectMapper = new ObjectMapper(); /** * Handles MethodologistSetupException. * * @param ex the exception * @param request the web request - * @return error response */ @ExceptionHandler(MethodologistSetupException.class) - public ResponseEntity handleMethodologistSetupException( - MethodologistSetupException ex, WebRequest request) { + public void handleMethodologistSetupException( + MethodologistSetupException ex, HttpServletRequest request, HttpServletResponse response) + throws IOException { log.error("MethodologistSetupException occurred: {}", ex.getMessage(), ex); - ErrorResponseDTO errorResponse = - ErrorResponseDTO.builder() - .errorCode(ex.getErrorCode()) - .message(ex.getMessage()) - .status(HttpStatus.BAD_REQUEST.value()) - .timestamp(System.currentTimeMillis()) - .path(request.getDescription(false).replace("uri=", "")) - .build(); - - return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + writeErrorResponse( + response, request, HttpStatus.BAD_REQUEST, ex.getErrorCode(), ex.getMessage()); } /** @@ -51,23 +44,15 @@ public ResponseEntity handleMethodologistSetupException( * * @param ex the exception * @param request the web request - * @return error response */ @ExceptionHandler(GenmodelException.class) - public ResponseEntity handleGenmodelException( - GenmodelException ex, WebRequest request) { + public void handleGenmodelException( + GenmodelException ex, HttpServletRequest request, HttpServletResponse response) + throws IOException { log.error("GenmodelException occurred: {}", ex.getMessage(), ex); - ErrorResponseDTO errorResponse = - ErrorResponseDTO.builder() - .errorCode(ex.getErrorCode()) - .message(ex.getMessage()) - .status(HttpStatus.UNPROCESSABLE_ENTITY.value()) - .timestamp(System.currentTimeMillis()) - .path(request.getDescription(false).replace("uri=", "")) - .build(); - - return new ResponseEntity<>(errorResponse, HttpStatus.UNPROCESSABLE_ENTITY); + writeErrorResponse( + response, request, HttpStatus.UNPROCESSABLE_ENTITY, ex.getErrorCode(), ex.getMessage()); } /** @@ -75,23 +60,15 @@ public ResponseEntity handleGenmodelException( * * @param ex the exception * @param request the web request - * @return error response with bad request status */ @ExceptionHandler(NoSuchFileException.class) - public ResponseEntity handleNoSuchFileException( - NoSuchFileException ex, WebRequest request) { + public void handleNoSuchFileException( + NoSuchFileException ex, HttpServletRequest request, HttpServletResponse response) + throws IOException { log.error("NoSuchFileException occurred: {}", ex.getMessage(), ex); - ErrorResponseDTO errorResponse = - ErrorResponseDTO.builder() - .errorCode("VSUM_ARTIFACT_NOT_FOUND") - .message(ex.getMessage()) - .status(HttpStatus.BAD_REQUEST.value()) - .timestamp(System.currentTimeMillis()) - .path(request.getDescription(false).replace("uri=", "")) - .build(); - - return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + writeErrorResponse( + response, request, HttpStatus.BAD_REQUEST, "VSUM_ARTIFACT_NOT_FOUND", ex.getMessage()); } /** @@ -99,21 +76,39 @@ public ResponseEntity handleNoSuchFileException( * * @param ex the exception * @param request the web request - * @return error response */ @ExceptionHandler(Exception.class) - public ResponseEntity handleGlobalException(Exception ex, WebRequest request) { + public void handleGlobalException( + Exception ex, HttpServletRequest request, HttpServletResponse response) throws IOException { log.error("Unexpected exception occurred: {}", ex.getMessage(), ex); + writeErrorResponse( + response, + request, + HttpStatus.INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + ErrorMessages.UNEXPECTED_ERROR); + } + + private void writeErrorResponse( + HttpServletResponse response, + HttpServletRequest request, + HttpStatus status, + String errorCode, + String message) + throws IOException { ErrorResponseDTO errorResponse = ErrorResponseDTO.builder() - .errorCode("INTERNAL_ERROR") - .message(ErrorMessages.UNEXPECTED_ERROR) - .status(HttpStatus.INTERNAL_SERVER_ERROR.value()) + .errorCode(errorCode) + .message(message) + .status(status.value()) .timestamp(System.currentTimeMillis()) - .path(request.getDescription(false).replace("uri=", "")) + .path(request.getRequestURI()) .build(); - return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + response.setStatus(status.value()); + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); } } diff --git a/src/main/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildService.java b/src/main/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildService.java index d5342d4..602962a 100644 --- a/src/main/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildService.java +++ b/src/main/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildService.java @@ -100,12 +100,14 @@ private byte[] buildArtifact( Map metamodelNamespaceMap = extractMetamodelNamespaceMap(modelPairs); return generator.generate(modelPairs, copiedReactionFiles, metamodelNamespaceMap); } catch (IOException | MissingModelException e) { + String reason = e.getMessage() != null ? e.getMessage() : e.toString(); throw new MethodologistSetupException( - VSUM_BUILD_ERROR_CODE, "Failed to build VSUM project archive", e); + VSUM_BUILD_ERROR_CODE, "Failed to build VSUM project archive: " + reason, e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); + String reason = e.getMessage() != null ? e.getMessage() : e.toString(); throw new MethodologistSetupException( - VSUM_BUILD_ERROR_CODE, "VSUM project build was interrupted", e); + VSUM_BUILD_ERROR_CODE, "VSUM project build was interrupted: " + reason, e); } finally { deleteRecursively(uploadWorkspace); } @@ -134,7 +136,10 @@ private void validateInputs( if (metamodelFiles.size() != genmodelFiles.size()) { throw new MethodologistSetupException( VSUM_INPUT_ERROR_CODE, - "Metamodel and genmodel file counts must be identical to build file pairs"); + String.format( + "Please upload the same number of metamodel and genmodel files " + + "(received %d metamodel file(s) and %d genmodel file(s)).", + metamodelFiles.size(), genmodelFiles.size())); } for (MultipartFile file : metamodelFiles) { diff --git a/src/test/java/tools/vitruv/methodologist/setup/vsum/controller/VsumControllerErrorHandlingTest.java b/src/test/java/tools/vitruv/methodologist/setup/vsum/controller/VsumControllerErrorHandlingTest.java new file mode 100644 index 0000000..7ea8da6 --- /dev/null +++ b/src/test/java/tools/vitruv/methodologist/setup/vsum/controller/VsumControllerErrorHandlingTest.java @@ -0,0 +1,64 @@ +package tools.vitruv.methodologist.setup.vsum.controller; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import tools.vitruv.methodologist.setup.config.GlobalExceptionHandler; +import tools.vitruv.methodologist.setup.exception.MethodologistSetupException; +import tools.vitruv.methodologist.setup.vsum.service.VsumProjectBuildService; + +@ExtendWith(MockitoExtension.class) +class VsumControllerErrorHandlingTest { + + @Test + void buildProject_shouldReturnJsonErrorForZipAcceptHeader() throws Exception { + VsumProjectBuildService buildService = mock(VsumProjectBuildService.class); + VsumController controller = new VsumController(buildService); + MockMvc mockMvc = + MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + + when(buildService.buildProjectArchive(anyList(), anyList(), anyList())) + .thenThrow( + new MethodologistSetupException( + "VSUM_INPUT_ERROR", + "Please upload the same number of metamodel and genmodel files")); + + MockMultipartFile metamodelFile = + new MockMultipartFile( + "metamodelFiles", "a.ecore", "application/xml", "".getBytes()); + MockMultipartFile genmodelFile = + new MockMultipartFile( + "genmodelFiles", "a.genmodel", "application/xml", "".getBytes()); + MockMultipartFile reactionFile = + new MockMultipartFile( + "reactionFiles", "a.reactions", "text/plain", "import \"x\"".getBytes()); + + mockMvc + .perform( + multipart("/api/vsum/build") + .file(metamodelFile) + .file(genmodelFile) + .file(reactionFile) + .accept(MediaType.parseMediaType("application/zip"))) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.errorCode").value("VSUM_INPUT_ERROR")) + .andExpect( + jsonPath("$.message") + .value("Please upload the same number of metamodel and genmodel files")); + } +} diff --git a/src/test/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildServiceTest.java b/src/test/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildServiceTest.java index dd2bb08..341df79 100644 --- a/src/test/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildServiceTest.java +++ b/src/test/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildServiceTest.java @@ -55,7 +55,8 @@ void buildProjectArchive_shouldThrowWhenCountsDoNotMatch() { List.of(mockMultipart("a.genmodel")), List.of())) .isInstanceOf(MethodologistSetupException.class) - .hasMessageContaining("counts must be identical"); + .hasMessageContaining("Please upload the same number of metamodel and genmodel files") + .hasMessageContaining("received 2 metamodel file(s) and 1 genmodel file(s)"); } @Test @@ -202,7 +203,8 @@ void buildProjectJar_shouldThrowWhenCountsDoNotMatch() { List.of(mockMultipart("a.genmodel")), List.of())) .isInstanceOf(MethodologistSetupException.class) - .hasMessageContaining("counts must be identical"); + .hasMessageContaining("Please upload the same number of metamodel and genmodel files") + .hasMessageContaining("received 2 metamodel file(s) and 1 genmodel file(s)"); } @Test