From 7585224d0dbbe4eab6e40c381bd783e6b7d24897 Mon Sep 17 00:00:00 2001 From: trygu Date: Wed, 18 Dec 2024 22:33:16 +0100 Subject: [PATCH 1/2] Added examples of ExceptionTypes --- .../api/controller/RestExceptionHandler.java | 66 ++-- .../api/controller/RestExceptionTypes.java | 19 ++ .../exception/CustomKubernetesException.java | 31 ++ .../api/services/impl/HelmAppsService.java | 286 +++++++++++------- .../controller/RestExceptionHandlerTest.java | 89 ++++++ 5 files changed, 361 insertions(+), 130 deletions(-) create mode 100644 onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionTypes.java create mode 100644 onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/CustomKubernetesException.java create mode 100644 onyxia-api/src/test/java/fr/insee/onyxia/api/controller/RestExceptionHandlerTest.java diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionHandler.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionHandler.java index 813bb9b0..535cd342 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionHandler.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionHandler.java @@ -1,36 +1,64 @@ package fr.insee.onyxia.api.controller; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import org.everit.json.schema.ValidationException; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import jakarta.servlet.http.HttpServletRequest; +import org.everit.json.schema.ValidationException; + +import java.net.URI; +import java.util.List; +import java.util.stream.Collectors; @RestControllerAdvice -public class RestExceptionHandler extends ResponseEntityExceptionHandler { +public class RestExceptionHandler { - @ExceptionHandler(AccessDeniedException.class) - public ProblemDetail handleAccessDeniedException(Exception ignored) { - ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.FORBIDDEN); - problemDetail.setTitle("Access denied"); + // Helper method to create ProblemDetail + private ProblemDetail createProblemDetail(HttpStatus status, URI type, String title, String detail, String instancePath) { + ProblemDetail problemDetail = ProblemDetail.forStatus(status); + problemDetail.setType(type); + problemDetail.setTitle(title); + problemDetail.setDetail(detail); + problemDetail.setInstance(URI.create(instancePath)); return problemDetail; } + // AccessDeniedException handler + @ExceptionHandler(AccessDeniedException.class) + public ProblemDetail handleAccessDeniedException(AccessDeniedException exception, HttpServletRequest request) { + return createProblemDetail( + HttpStatus.FORBIDDEN, + RestExceptionTypes.ACCESS_DENIED, + "Access Denied", + "You do not have permission to access this resource.", + request.getRequestURI() + ); + } + + // ValidationException handler @ExceptionHandler(ValidationException.class) - public ProblemDetail handleValidationException(ValidationException ex) { - List errors = - ex.getCausingExceptions().stream() - .map(ValidationException::getMessage) - .collect(Collectors.toList()); - - ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); - problemDetail.setProperties(Map.of("errors", errors)); - problemDetail.setTitle("Validation failed"); + public ProblemDetail handleValidationException(ValidationException ex, HttpServletRequest request) { + List errors = ex.getCausingExceptions() != null && !ex.getCausingExceptions().isEmpty() + ? ex.getCausingExceptions().stream() + .map(ValidationException::getMessage) + .collect(Collectors.toList()) + : List.of(ex.getMessage()); // Fallback to the main exception message if causing exceptions are empty. + + ProblemDetail problemDetail = createProblemDetail( + HttpStatus.BAD_REQUEST, + RestExceptionTypes.VALIDATION_FAILED, + "Validation Failed", + "The request contains invalid data.", + request.getRequestURI() + ); + + // Add 'errors' as a custom property + problemDetail.setProperty("errors", errors); return problemDetail; } + + } diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionTypes.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionTypes.java new file mode 100644 index 00000000..7a992655 --- /dev/null +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionTypes.java @@ -0,0 +1,19 @@ +package fr.insee.onyxia.api.controller; + +import java.net.URI; + +// These are mostly examples +public class RestExceptionTypes { + public static final URI ACCESS_DENIED = URI.create("urn:org:onyxia:api:error:access-denied"); + public static final URI VALIDATION_FAILED = URI.create("urn:org:onyxia:api:error:validation-failed"); + public static final URI INVALID_ARGUMENT = URI.create("urn:org:onyxia:api:error:invalid-argument"); + public static final URI INSTALLATION_FAILURE = URI.create("urn:org:onyxia:api:error:installation-failure"); + public static final URI NAMESPACE_NOT_FOUND = URI.create("urn:org:onyxia:api:error:namespace-not-found"); + public static final URI HELM_LIST_FAILURE = URI.create("urn:org:onyxia:api:error:helm-list-failure"); + public static final URI HELM_RELEASE_FETCH_FAILURE = URI.create("urn:org:onyxia:api:error:helm-release-fetch-failure"); + public static final URI GENERIC_ERROR = URI.create("urn:org:onyxia:api:error:unknown-error"); + + private RestExceptionTypes() { + // Prevent instantiation + } +} diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/CustomKubernetesException.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/CustomKubernetesException.java new file mode 100644 index 00000000..0b0f92df --- /dev/null +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/CustomKubernetesException.java @@ -0,0 +1,31 @@ +package fr.insee.onyxia.api.controller.exception; + +import org.springframework.http.ProblemDetail; + +/** + * Custom exception class for propagating Kubernetes-related errors + * with structured ProblemDetail information. + */ +public class CustomKubernetesException extends RuntimeException { + + private final ProblemDetail problemDetail; + + /** + * Constructor to create a CustomKubernetesException. + * + * @param problemDetail The ProblemDetail containing error details + */ + public CustomKubernetesException(ProblemDetail problemDetail) { + super(problemDetail.getDetail()); + this.problemDetail = problemDetail; + } + + /** + * Getter for the ProblemDetail object. + * + * @return ProblemDetail containing structured error details + */ + public ProblemDetail getProblemDetail() { + return problemDetail; + } +} diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java index 48768b64..92fe7944 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java @@ -3,6 +3,8 @@ import static fr.insee.onyxia.api.services.impl.HelmReleaseHealthResolver.checkHelmReleaseHealth; import static fr.insee.onyxia.api.services.impl.ServiceUrlResolver.getServiceUrls; +import fr.insee.onyxia.api.controller.exception.CustomKubernetesException; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import fr.insee.onyxia.api.configuration.kubernetes.HelmClientProvider; @@ -22,6 +24,8 @@ import fr.insee.onyxia.model.project.Project; import fr.insee.onyxia.model.region.Region; import fr.insee.onyxia.model.service.*; +import fr.insee.onyxia.api.controller.RestExceptionTypes; +import fr.insee.onyxia.api.controller.exception.*; import io.fabric8.kubernetes.api.model.Secret; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.Watch; @@ -45,6 +49,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.access.AccessDeniedException; +import org.springframework.http.ProblemDetail; +import org.springframework.http.HttpStatus; +import java.net.URI; @org.springframework.stereotype.Service @Qualifier("Helm") @@ -93,6 +100,15 @@ private HelmInstallService getHelmInstallService() { return helmClientProvider.defaultHelmInstallService(); } + private ProblemDetail createProblemDetail(HttpStatus status, URI type, String title, String detail, String instance) { + ProblemDetail problemDetail = ProblemDetail.forStatus(status); + problemDetail.setType(type); + problemDetail.setTitle(title); + problemDetail.setDetail(detail); + problemDetail.setInstance(URI.create(instance)); + return problemDetail; + } + @Override public Collection installApp( Region region, @@ -146,7 +162,28 @@ public Collection installApp( region, namespaceId, requestDTO.getName(), metadata); return List.of(res.getManifest()); } catch (IllegalArgumentException e) { - throw new AccessDeniedException(e.getMessage()); + String instanceUri = String.format("/install-app/%s/%s/%s", + namespaceId, catalogId, requestDTO.getName()); + throw new CustomKubernetesException( + createProblemDetail( + HttpStatus.BAD_REQUEST, + RestExceptionTypes.INVALID_ARGUMENT, + "Invalid Argument", + e.getMessage(), + instanceUri)); + } catch (Exception e) { + LOGGER.error("Unexpected error during app installation", e); + + String instanceUri = String.format("/install-app/%s/%s/%s", + namespaceId, catalogId, requestDTO.getName()); + + throw new CustomKubernetesException( + createProblemDetail( + HttpStatus.INTERNAL_SERVER_ERROR, + RestExceptionTypes.INSTALLATION_FAILURE, + "Installation Failure", + "An unexpected error occurred while installing the app. Please try again later.", + instanceUri)); } finally { if (!values.delete()) { LOGGER.warn("Failed to delete values file, path {}", values.getAbsolutePath()); @@ -162,26 +199,44 @@ public CompletableFuture getUserServices( @Override public CompletableFuture getUserServices( - Region region, Project project, User user, String groupId) - throws IOException, IllegalAccessException { - if (groupId != null) { - LOGGER.debug("STUB : group listing is currently not supported on helm"); - return CompletableFuture.completedFuture(new ServicesListing()); - } - if (StringUtils.isEmpty(project.getNamespace())) { - throw new NamespaceNotFoundException(); - } - List installedCharts = null; - try { - installedCharts = - Arrays.asList( - getHelmInstallService() - .listChartInstall( - getHelmConfiguration(region, user), - project.getNamespace())); - } catch (Exception e) { - return CompletableFuture.completedFuture(new ServicesListing()); - } + Region region, Project project, User user, String groupId) + throws IOException, IllegalAccessException { + if (groupId != null) { + LOGGER.debug("STUB : group listing is currently not supported on helm"); + return CompletableFuture.completedFuture(new ServicesListing()); + } + if (StringUtils.isEmpty(project.getNamespace())) { + String instanceUri = "/projects/" + project.getId() + "/namespace"; + throw new CustomKubernetesException( + createProblemDetail( + HttpStatus.NOT_FOUND, + RestExceptionTypes.NAMESPACE_NOT_FOUND, + "Namespace Not Found", + "The namespace for the provided project is empty or not defined.", + instanceUri + ) + ); + } + List installedCharts; + try { + installedCharts = Arrays.asList( + getHelmInstallService() + .listChartInstall( + getHelmConfiguration(region, user), + project.getNamespace())); + } catch (Exception e) { + LOGGER.error("Failed to list installed Helm charts for namespace {}", project.getNamespace(), e); + String instanceUri = "/namespaces/" + project.getNamespace() + "/helm-list"; + throw new CustomKubernetesException( + createProblemDetail( + HttpStatus.INTERNAL_SERVER_ERROR, + RestExceptionTypes.HELM_LIST_FAILURE, + "Helm List Failure", + "Failed to retrieve the list of installed Helm charts. Please try again later.", + instanceUri + ) + ); + } List services = installedCharts.parallelStream() .map(release -> getHelmApp(region, user, release)) @@ -284,18 +339,34 @@ public UninstallService destroyService( } private Service getHelmApp(Region region, User user, HelmLs release) { - HelmReleaseInfo helmReleaseInfo = - getHelmInstallService() - .getAll( - getHelmConfiguration(region, user), - release.getName(), - release.getNamespace()); - Service service = - getServiceFromRelease(region, release, helmReleaseInfo.getManifest(), user); + HelmReleaseInfo helmReleaseInfo; + try { + helmReleaseInfo = getHelmInstallService().getAll( + getHelmConfiguration(region, user), + release.getName(), + release.getNamespace()); + } catch (Exception e) { + LOGGER.error("Failed to retrieve Helm release info for release {} in namespace {}", + release.getName(), release.getNamespace(), e); + String instanceUri = "/releases/" + release.getName(); + throw new CustomKubernetesException( + createProblemDetail( + HttpStatus.INTERNAL_SERVER_ERROR, + RestExceptionTypes.HELM_RELEASE_FETCH_FAILURE, + "Helm Release Fetch Failure", + "Failed to retrieve Helm release information.", + instanceUri + ) + ); + } + + Service service = getServiceFromRelease(region, release, helmReleaseInfo.getManifest(), user); + try { service.setStartedAt(helmDateFormat.parse(release.getUpdated()).getTime()); } catch (Exception e) { - service.setStartedAt(0); + LOGGER.warn("Failed to parse release updated date for {}", release.getName(), e); + service.setStartedAt(0); // Fallback to 0 if parsing fails } try { KubernetesClient client = kubernetesClientProvider.getUserClient(region, user); @@ -321,41 +392,43 @@ private Service getHelmApp(Region region, User user, HelmLs release) { } } } catch (Exception e) { - LOGGER.warn("Exception occurred", e); + LOGGER.warn("Failed to retrieve or decode Onyxia secret for release {}", release.getName(), e); } + service.setId(release.getName()); service.setName(release.getName()); service.setSubtitle(release.getChart()); - service.setName(release.getName()); service.setNamespace(release.getNamespace()); service.setRevision(release.getRevision()); service.setStatus(release.getStatus()); service.setUpdated(release.getUpdated()); service.setChart(release.getChart()); service.setAppVersion(release.getAppVersion()); + try { String values = helmReleaseInfo.getUserSuppliedValues(); JsonNode node = mapperHelm.readTree(values); Map result = new HashMap<>(); - node.fields() - .forEachRemaining( - currentNode -> mapAppender(result, currentNode, new ArrayList<>())); + node.fields().forEachRemaining( + currentNode -> mapAppender(result, currentNode, new ArrayList<>())); service.setEnv(result); service.setSuspendable(service.getEnv().containsKey(SUSPEND_KEY)); if (service.getEnv().containsKey(SUSPEND_KEY)) { service.setSuspended(Boolean.parseBoolean(service.getEnv().get(SUSPEND_KEY))); } } catch (Exception e) { - LOGGER.warn("Exception occurred", e); + LOGGER.warn("Failed to parse user-supplied values for release {}", release.getName(), e); } + try { String notes = helmReleaseInfo.getNotes(); service.setPostInstallInstructions(notes); } catch (Exception e) { - LOGGER.warn("Exception occurred", e); + LOGGER.warn("Failed to retrieve post-install instructions for release {}", release.getName(), e); } + return service; - } + } @Override public void rename( @@ -520,79 +593,70 @@ private void mapAppender( } } - private Service getServiceFromRelease( - Region region, HelmLs release, String manifest, User user) { - KubernetesClient client = kubernetesClientProvider.getUserClient(region, user); - - Service service = new Service(); - - try { - List urls = getServiceUrls(region, manifest, client); - service.setUrls(urls); - } catch (Exception e) { - LOGGER.warn( - "Failed to retrieve URLS for release {} namespace {}", - release.getName(), - release.getNamespace(), - e); - service.setUrls(List.of()); - } - - try { - List controllers = - checkHelmReleaseHealth(release.getNamespace(), manifest, client); - service.setControllers(controllers); - } catch (Exception e) { - LOGGER.warn( - "Failed to retrieve controllers for release {} namespace {}", - release.getName(), - release.getNamespace(), - e); - service.setControllers(List.of()); - } - service.setInstances(1); - - service.setTasks( - client - .pods() - .inNamespace(release.getNamespace()) - .withLabel("app.kubernetes.io/instance", release.getName()) - .list() - .getItems() - .stream() - .map( - pod -> { - Task currentTask = new Task(); - currentTask.setId(pod.getMetadata().getName()); - TaskStatus status = new TaskStatus(); - status.setStatus(pod.getStatus().getPhase()); - status.setReason( - pod.getStatus().getContainerStatuses().stream() - .filter( - cstatus -> - cstatus.getState().getWaiting() - != null) - .map( - cstatus -> - cstatus.getState() - .getWaiting() - .getReason()) - .findFirst() - .orElse(null)); - pod.getStatus() - .getContainerStatuses() - .forEach( - c -> { - Container container = new Container(); - container.setName(c.getName()); - container.setReady(c.getReady()); - currentTask.getContainers().add(container); - }); - currentTask.setStatus(status); - return currentTask; - }) - .toList()); +private Service getServiceFromRelease(Region region, HelmLs release, String manifest, User user) { + KubernetesClient client = kubernetesClientProvider.getUserClient(region, user); + + Service service = new Service(); + + try { + List urls = getServiceUrls(region, manifest, client); + service.setUrls(urls); + } catch (Exception e) { + LOGGER.warn( + "Failed to retrieve URLs for release {} in namespace {}. Region: {}, User: {}", + release.getName(), + release.getNamespace(), + region.getName(), + user.getIdep(), + e); + service.setUrls(List.of()); + } - return service; + try { + List controllers = checkHelmReleaseHealth(release.getNamespace(), manifest, client); + service.setControllers(controllers); + } catch (Exception e) { + LOGGER.warn( + "Failed to retrieve controllers for release {} in namespace {}. Region: {}, User: {}", + release.getName(), + release.getNamespace(), + region.getName(), + user.getIdep(), + e); + service.setControllers(List.of()); } + + service.setInstances(1); + + service.setTasks( + client.pods() + .inNamespace(release.getNamespace()) + .withLabel("app.kubernetes.io/instance", release.getName()) + .list() + .getItems() + .stream() + .map(pod -> { + Task currentTask = new Task(); + currentTask.setId(pod.getMetadata().getName()); + TaskStatus status = new TaskStatus(); + status.setStatus(pod.getStatus().getPhase()); + status.setReason( + pod.getStatus().getContainerStatuses().stream() + .filter(cstatus -> cstatus.getState().getWaiting() != null) + .map(cstatus -> cstatus.getState().getWaiting().getReason()) + .findFirst() + .orElse(null)); + pod.getStatus().getContainerStatuses().forEach(c -> { + Container container = new Container(); + container.setName(c.getName()); + container.setReady(c.getReady()); + currentTask.getContainers().add(container); + }); + currentTask.setStatus(status); + return currentTask; + }) + .toList()); + + return service; +} } diff --git a/onyxia-api/src/test/java/fr/insee/onyxia/api/controller/RestExceptionHandlerTest.java b/onyxia-api/src/test/java/fr/insee/onyxia/api/controller/RestExceptionHandlerTest.java new file mode 100644 index 00000000..1fe2a9e6 --- /dev/null +++ b/onyxia-api/src/test/java/fr/insee/onyxia/api/controller/RestExceptionHandlerTest.java @@ -0,0 +1,89 @@ +package fr.insee.onyxia.api.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.security.access.AccessDeniedException; +import org.everit.json.schema.Schema; +import org.everit.json.schema.ValidationException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import java.net.URI; +import java.util.List; + +class RestExceptionHandlerTest { + + private RestExceptionHandler restExceptionHandler; + private HttpServletRequest mockRequest; + + @BeforeEach + void setUp() { + restExceptionHandler = new RestExceptionHandler(); + mockRequest = Mockito.mock(HttpServletRequest.class); + } + + @Test + void testHandleAccessDeniedForNamespaceCreation() { + when(mockRequest.getRequestURI()).thenReturn("/onboarding"); + + AccessDeniedException exception = new AccessDeniedException("Access Denied for Namespace Creation"); + ProblemDetail result = restExceptionHandler.handleAccessDeniedException(exception, mockRequest); + + assertEquals(HttpStatus.FORBIDDEN.value(), result.getStatus()); + assertEquals(RestExceptionTypes.ACCESS_DENIED, result.getType()); + assertEquals("Access Denied", result.getTitle()); + assertEquals("You do not have permission to access this resource.", result.getDetail()); + assertEquals(URI.create("/onboarding"), result.getInstance()); + } + + @Test + void testHandleValidationException() { + when(mockRequest.getRequestURI()).thenReturn("/my-lab/app"); + + ValidationException validationException = Mockito.mock(ValidationException.class); + when(validationException.getMessage()).thenReturn("Validation Error Message"); + + ProblemDetail result = restExceptionHandler.handleValidationException(validationException, mockRequest); + + assertEquals(HttpStatus.BAD_REQUEST.value(), result.getStatus()); + assertEquals(RestExceptionTypes.VALIDATION_FAILED, result.getType()); + assertEquals("Validation Failed", result.getTitle()); + assertEquals("The request contains invalid data.", result.getDetail()); + assertEquals(URI.create("/my-lab/app"), result.getInstance()); + assertThat(result.getProperties()).containsKey("errors"); + } + + @Test + void testHandleValidationExceptionWithErrors() { + when(mockRequest.getRequestURI()).thenReturn("/my-lab/app"); + + Schema mockSchema = Mockito.mock(Schema.class); + + ValidationException ex1 = new ValidationException(mockSchema, "Field 'name' is required","name"); + ValidationException ex2 = new ValidationException(mockSchema, "Field 'version' must be a number","version"); + + ValidationException validationException = Mockito.mock(ValidationException.class); + when(validationException.getMessage()).thenReturn("Validation Error Message"); + when(validationException.getCausingExceptions()).thenReturn(List.of(ex1, ex2)); + + ProblemDetail result = restExceptionHandler.handleValidationException(validationException, mockRequest); + + assertEquals(HttpStatus.BAD_REQUEST.value(), result.getStatus()); + assertEquals(RestExceptionTypes.VALIDATION_FAILED, result.getType()); + assertEquals("Validation Failed", result.getTitle()); + assertEquals("The request contains invalid data.", result.getDetail()); + assertEquals(URI.create("/my-lab/app"), result.getInstance()); + + @SuppressWarnings("unchecked") + List errors = (List) result.getProperties().get("errors"); + assertThat(errors).containsExactly( + "#: Field 'name' is required", + "#: Field 'version' must be a number"); + } +} From 4ae1ed85bd61b5346af0606e079039901dfe6677 Mon Sep 17 00:00:00 2001 From: trygu Date: Wed, 18 Dec 2024 22:44:13 +0100 Subject: [PATCH 2/2] Should be spotless now. --- .../api/controller/RestExceptionHandler.java | 52 +-- .../api/controller/RestExceptionTypes.java | 18 +- .../exception/CustomKubernetesException.java | 10 +- .../api/services/impl/HelmAppsService.java | 336 ++++++++++-------- .../controller/RestExceptionHandlerTest.java | 45 +-- 5 files changed, 249 insertions(+), 212 deletions(-) diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionHandler.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionHandler.java index 535cd342..dc62b6d2 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionHandler.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionHandler.java @@ -1,23 +1,22 @@ package fr.insee.onyxia.api.controller; +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import java.util.List; +import java.util.stream.Collectors; +import org.everit.json.schema.ValidationException; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import jakarta.servlet.http.HttpServletRequest; -import org.everit.json.schema.ValidationException; - -import java.net.URI; -import java.util.List; -import java.util.stream.Collectors; - @RestControllerAdvice public class RestExceptionHandler { // Helper method to create ProblemDetail - private ProblemDetail createProblemDetail(HttpStatus status, URI type, String title, String detail, String instancePath) { + private ProblemDetail createProblemDetail( + HttpStatus status, URI type, String title, String detail, String instancePath) { ProblemDetail problemDetail = ProblemDetail.forStatus(status); problemDetail.setType(type); problemDetail.setTitle(title); @@ -28,37 +27,38 @@ private ProblemDetail createProblemDetail(HttpStatus status, URI type, String ti // AccessDeniedException handler @ExceptionHandler(AccessDeniedException.class) - public ProblemDetail handleAccessDeniedException(AccessDeniedException exception, HttpServletRequest request) { + public ProblemDetail handleAccessDeniedException( + AccessDeniedException exception, HttpServletRequest request) { return createProblemDetail( HttpStatus.FORBIDDEN, RestExceptionTypes.ACCESS_DENIED, "Access Denied", "You do not have permission to access this resource.", - request.getRequestURI() - ); + request.getRequestURI()); } // ValidationException handler @ExceptionHandler(ValidationException.class) - public ProblemDetail handleValidationException(ValidationException ex, HttpServletRequest request) { - List errors = ex.getCausingExceptions() != null && !ex.getCausingExceptions().isEmpty() - ? ex.getCausingExceptions().stream() - .map(ValidationException::getMessage) - .collect(Collectors.toList()) - : List.of(ex.getMessage()); // Fallback to the main exception message if causing exceptions are empty. + public ProblemDetail handleValidationException( + ValidationException ex, HttpServletRequest request) { + List errors = + ex.getCausingExceptions() != null && !ex.getCausingExceptions().isEmpty() + ? ex.getCausingExceptions().stream() + .map(ValidationException::getMessage) + .collect(Collectors.toList()) + : List.of(ex.getMessage()); // Fallback to the main exception message if + // causing exceptions are empty. - ProblemDetail problemDetail = createProblemDetail( - HttpStatus.BAD_REQUEST, - RestExceptionTypes.VALIDATION_FAILED, - "Validation Failed", - "The request contains invalid data.", - request.getRequestURI() - ); + ProblemDetail problemDetail = + createProblemDetail( + HttpStatus.BAD_REQUEST, + RestExceptionTypes.VALIDATION_FAILED, + "Validation Failed", + "The request contains invalid data.", + request.getRequestURI()); // Add 'errors' as a custom property problemDetail.setProperty("errors", errors); return problemDetail; } - - } diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionTypes.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionTypes.java index 7a992655..341956f3 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionTypes.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/RestExceptionTypes.java @@ -5,12 +5,18 @@ // These are mostly examples public class RestExceptionTypes { public static final URI ACCESS_DENIED = URI.create("urn:org:onyxia:api:error:access-denied"); - public static final URI VALIDATION_FAILED = URI.create("urn:org:onyxia:api:error:validation-failed"); - public static final URI INVALID_ARGUMENT = URI.create("urn:org:onyxia:api:error:invalid-argument"); - public static final URI INSTALLATION_FAILURE = URI.create("urn:org:onyxia:api:error:installation-failure"); - public static final URI NAMESPACE_NOT_FOUND = URI.create("urn:org:onyxia:api:error:namespace-not-found"); - public static final URI HELM_LIST_FAILURE = URI.create("urn:org:onyxia:api:error:helm-list-failure"); - public static final URI HELM_RELEASE_FETCH_FAILURE = URI.create("urn:org:onyxia:api:error:helm-release-fetch-failure"); + public static final URI VALIDATION_FAILED = + URI.create("urn:org:onyxia:api:error:validation-failed"); + public static final URI INVALID_ARGUMENT = + URI.create("urn:org:onyxia:api:error:invalid-argument"); + public static final URI INSTALLATION_FAILURE = + URI.create("urn:org:onyxia:api:error:installation-failure"); + public static final URI NAMESPACE_NOT_FOUND = + URI.create("urn:org:onyxia:api:error:namespace-not-found"); + public static final URI HELM_LIST_FAILURE = + URI.create("urn:org:onyxia:api:error:helm-list-failure"); + public static final URI HELM_RELEASE_FETCH_FAILURE = + URI.create("urn:org:onyxia:api:error:helm-release-fetch-failure"); public static final URI GENERIC_ERROR = URI.create("urn:org:onyxia:api:error:unknown-error"); private RestExceptionTypes() { diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/CustomKubernetesException.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/CustomKubernetesException.java index 0b0f92df..9e2914e1 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/CustomKubernetesException.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/CustomKubernetesException.java @@ -3,16 +3,16 @@ import org.springframework.http.ProblemDetail; /** - * Custom exception class for propagating Kubernetes-related errors - * with structured ProblemDetail information. + * Custom exception class for propagating Kubernetes-related errors with structured ProblemDetail + * information. */ public class CustomKubernetesException extends RuntimeException { - + private final ProblemDetail problemDetail; /** * Constructor to create a CustomKubernetesException. - * + * * @param problemDetail The ProblemDetail containing error details */ public CustomKubernetesException(ProblemDetail problemDetail) { @@ -22,7 +22,7 @@ public CustomKubernetesException(ProblemDetail problemDetail) { /** * Getter for the ProblemDetail object. - * + * * @return ProblemDetail containing structured error details */ public ProblemDetail getProblemDetail() { diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java index 92fe7944..cf7e1c3d 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java @@ -3,13 +3,13 @@ import static fr.insee.onyxia.api.services.impl.HelmReleaseHealthResolver.checkHelmReleaseHealth; import static fr.insee.onyxia.api.services.impl.ServiceUrlResolver.getServiceUrls; -import fr.insee.onyxia.api.controller.exception.CustomKubernetesException; - import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import fr.insee.onyxia.api.configuration.kubernetes.HelmClientProvider; import fr.insee.onyxia.api.configuration.kubernetes.KubernetesClientProvider; -import fr.insee.onyxia.api.controller.exception.NamespaceNotFoundException; +import fr.insee.onyxia.api.controller.RestExceptionTypes; +import fr.insee.onyxia.api.controller.exception.*; +import fr.insee.onyxia.api.controller.exception.CustomKubernetesException; import fr.insee.onyxia.api.events.InstallServiceEvent; import fr.insee.onyxia.api.events.OnyxiaEventPublisher; import fr.insee.onyxia.api.events.SuspendResumeServiceEvent; @@ -24,8 +24,6 @@ import fr.insee.onyxia.model.project.Project; import fr.insee.onyxia.model.region.Region; import fr.insee.onyxia.model.service.*; -import fr.insee.onyxia.api.controller.RestExceptionTypes; -import fr.insee.onyxia.api.controller.exception.*; import io.fabric8.kubernetes.api.model.Secret; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.Watch; @@ -38,6 +36,7 @@ import io.github.inseefrlab.helmwrapper.service.HelmInstallService.MultipleServiceFound; import java.io.File; import java.io.IOException; +import java.net.URI; import java.text.ParseException; import java.util.*; import java.util.concurrent.CompletableFuture; @@ -48,10 +47,8 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.http.ProblemDetail; import org.springframework.http.HttpStatus; -import java.net.URI; +import org.springframework.http.ProblemDetail; @org.springframework.stereotype.Service @Qualifier("Helm") @@ -100,7 +97,8 @@ private HelmInstallService getHelmInstallService() { return helmClientProvider.defaultHelmInstallService(); } - private ProblemDetail createProblemDetail(HttpStatus status, URI type, String title, String detail, String instance) { + private ProblemDetail createProblemDetail( + HttpStatus status, URI type, String title, String detail, String instance) { ProblemDetail problemDetail = ProblemDetail.forStatus(status); problemDetail.setType(type); problemDetail.setTitle(title); @@ -162,28 +160,30 @@ public Collection installApp( region, namespaceId, requestDTO.getName(), metadata); return List.of(res.getManifest()); } catch (IllegalArgumentException e) { - String instanceUri = String.format("/install-app/%s/%s/%s", - namespaceId, catalogId, requestDTO.getName()); + String instanceUri = + String.format( + "/install-app/%s/%s/%s", namespaceId, catalogId, requestDTO.getName()); throw new CustomKubernetesException( - createProblemDetail( - HttpStatus.BAD_REQUEST, - RestExceptionTypes.INVALID_ARGUMENT, - "Invalid Argument", - e.getMessage(), - instanceUri)); + createProblemDetail( + HttpStatus.BAD_REQUEST, + RestExceptionTypes.INVALID_ARGUMENT, + "Invalid Argument", + e.getMessage(), + instanceUri)); } catch (Exception e) { LOGGER.error("Unexpected error during app installation", e); - - String instanceUri = String.format("/install-app/%s/%s/%s", - namespaceId, catalogId, requestDTO.getName()); - + + String instanceUri = + String.format( + "/install-app/%s/%s/%s", namespaceId, catalogId, requestDTO.getName()); + throw new CustomKubernetesException( - createProblemDetail( - HttpStatus.INTERNAL_SERVER_ERROR, - RestExceptionTypes.INSTALLATION_FAILURE, - "Installation Failure", - "An unexpected error occurred while installing the app. Please try again later.", - instanceUri)); + createProblemDetail( + HttpStatus.INTERNAL_SERVER_ERROR, + RestExceptionTypes.INSTALLATION_FAILURE, + "Installation Failure", + "An unexpected error occurred while installing the app. Please try again later.", + instanceUri)); } finally { if (!values.delete()) { LOGGER.warn("Failed to delete values file, path {}", values.getAbsolutePath()); @@ -199,44 +199,44 @@ public CompletableFuture getUserServices( @Override public CompletableFuture getUserServices( - Region region, Project project, User user, String groupId) - throws IOException, IllegalAccessException { - if (groupId != null) { - LOGGER.debug("STUB : group listing is currently not supported on helm"); - return CompletableFuture.completedFuture(new ServicesListing()); - } - if (StringUtils.isEmpty(project.getNamespace())) { - String instanceUri = "/projects/" + project.getId() + "/namespace"; - throw new CustomKubernetesException( - createProblemDetail( - HttpStatus.NOT_FOUND, - RestExceptionTypes.NAMESPACE_NOT_FOUND, - "Namespace Not Found", - "The namespace for the provided project is empty or not defined.", - instanceUri - ) - ); - } - List installedCharts; - try { - installedCharts = Arrays.asList( - getHelmInstallService() - .listChartInstall( - getHelmConfiguration(region, user), - project.getNamespace())); - } catch (Exception e) { - LOGGER.error("Failed to list installed Helm charts for namespace {}", project.getNamespace(), e); - String instanceUri = "/namespaces/" + project.getNamespace() + "/helm-list"; - throw new CustomKubernetesException( - createProblemDetail( - HttpStatus.INTERNAL_SERVER_ERROR, - RestExceptionTypes.HELM_LIST_FAILURE, - "Helm List Failure", - "Failed to retrieve the list of installed Helm charts. Please try again later.", - instanceUri - ) - ); - } + Region region, Project project, User user, String groupId) + throws IOException, IllegalAccessException { + if (groupId != null) { + LOGGER.debug("STUB : group listing is currently not supported on helm"); + return CompletableFuture.completedFuture(new ServicesListing()); + } + if (StringUtils.isEmpty(project.getNamespace())) { + String instanceUri = "/projects/" + project.getId() + "/namespace"; + throw new CustomKubernetesException( + createProblemDetail( + HttpStatus.NOT_FOUND, + RestExceptionTypes.NAMESPACE_NOT_FOUND, + "Namespace Not Found", + "The namespace for the provided project is empty or not defined.", + instanceUri)); + } + List installedCharts; + try { + installedCharts = + Arrays.asList( + getHelmInstallService() + .listChartInstall( + getHelmConfiguration(region, user), + project.getNamespace())); + } catch (Exception e) { + LOGGER.error( + "Failed to list installed Helm charts for namespace {}", + project.getNamespace(), + e); + String instanceUri = "/namespaces/" + project.getNamespace() + "/helm-list"; + throw new CustomKubernetesException( + createProblemDetail( + HttpStatus.INTERNAL_SERVER_ERROR, + RestExceptionTypes.HELM_LIST_FAILURE, + "Helm List Failure", + "Failed to retrieve the list of installed Helm charts. Please try again later.", + instanceUri)); + } List services = installedCharts.parallelStream() .map(release -> getHelmApp(region, user, release)) @@ -341,27 +341,31 @@ public UninstallService destroyService( private Service getHelmApp(Region region, User user, HelmLs release) { HelmReleaseInfo helmReleaseInfo; try { - helmReleaseInfo = getHelmInstallService().getAll( - getHelmConfiguration(region, user), - release.getName(), - release.getNamespace()); + helmReleaseInfo = + getHelmInstallService() + .getAll( + getHelmConfiguration(region, user), + release.getName(), + release.getNamespace()); } catch (Exception e) { - LOGGER.error("Failed to retrieve Helm release info for release {} in namespace {}", - release.getName(), release.getNamespace(), e); + LOGGER.error( + "Failed to retrieve Helm release info for release {} in namespace {}", + release.getName(), + release.getNamespace(), + e); String instanceUri = "/releases/" + release.getName(); throw new CustomKubernetesException( - createProblemDetail( - HttpStatus.INTERNAL_SERVER_ERROR, - RestExceptionTypes.HELM_RELEASE_FETCH_FAILURE, - "Helm Release Fetch Failure", - "Failed to retrieve Helm release information.", - instanceUri - ) - ); + createProblemDetail( + HttpStatus.INTERNAL_SERVER_ERROR, + RestExceptionTypes.HELM_RELEASE_FETCH_FAILURE, + "Helm Release Fetch Failure", + "Failed to retrieve Helm release information.", + instanceUri)); } - - Service service = getServiceFromRelease(region, release, helmReleaseInfo.getManifest(), user); - + + Service service = + getServiceFromRelease(region, release, helmReleaseInfo.getManifest(), user); + try { service.setStartedAt(helmDateFormat.parse(release.getUpdated()).getTime()); } catch (Exception e) { @@ -392,9 +396,12 @@ private Service getHelmApp(Region region, User user, HelmLs release) { } } } catch (Exception e) { - LOGGER.warn("Failed to retrieve or decode Onyxia secret for release {}", release.getName(), e); + LOGGER.warn( + "Failed to retrieve or decode Onyxia secret for release {}", + release.getName(), + e); } - + service.setId(release.getName()); service.setName(release.getName()); service.setSubtitle(release.getChart()); @@ -404,31 +411,36 @@ private Service getHelmApp(Region region, User user, HelmLs release) { service.setUpdated(release.getUpdated()); service.setChart(release.getChart()); service.setAppVersion(release.getAppVersion()); - + try { String values = helmReleaseInfo.getUserSuppliedValues(); JsonNode node = mapperHelm.readTree(values); Map result = new HashMap<>(); - node.fields().forEachRemaining( - currentNode -> mapAppender(result, currentNode, new ArrayList<>())); + node.fields() + .forEachRemaining( + currentNode -> mapAppender(result, currentNode, new ArrayList<>())); service.setEnv(result); service.setSuspendable(service.getEnv().containsKey(SUSPEND_KEY)); if (service.getEnv().containsKey(SUSPEND_KEY)) { service.setSuspended(Boolean.parseBoolean(service.getEnv().get(SUSPEND_KEY))); } } catch (Exception e) { - LOGGER.warn("Failed to parse user-supplied values for release {}", release.getName(), e); + LOGGER.warn( + "Failed to parse user-supplied values for release {}", release.getName(), e); } - + try { String notes = helmReleaseInfo.getNotes(); service.setPostInstallInstructions(notes); } catch (Exception e) { - LOGGER.warn("Failed to retrieve post-install instructions for release {}", release.getName(), e); + LOGGER.warn( + "Failed to retrieve post-install instructions for release {}", + release.getName(), + e); } return service; - } + } @Override public void rename( @@ -593,70 +605,84 @@ private void mapAppender( } } -private Service getServiceFromRelease(Region region, HelmLs release, String manifest, User user) { - KubernetesClient client = kubernetesClientProvider.getUserClient(region, user); - - Service service = new Service(); - - try { - List urls = getServiceUrls(region, manifest, client); - service.setUrls(urls); - } catch (Exception e) { - LOGGER.warn( - "Failed to retrieve URLs for release {} in namespace {}. Region: {}, User: {}", - release.getName(), - release.getNamespace(), - region.getName(), - user.getIdep(), - e); - service.setUrls(List.of()); - } + private Service getServiceFromRelease( + Region region, HelmLs release, String manifest, User user) { + KubernetesClient client = kubernetesClientProvider.getUserClient(region, user); - try { - List controllers = checkHelmReleaseHealth(release.getNamespace(), manifest, client); - service.setControllers(controllers); - } catch (Exception e) { - LOGGER.warn( - "Failed to retrieve controllers for release {} in namespace {}. Region: {}, User: {}", - release.getName(), - release.getNamespace(), - region.getName(), - user.getIdep(), - e); - service.setControllers(List.of()); - } + Service service = new Service(); - service.setInstances(1); - - service.setTasks( - client.pods() - .inNamespace(release.getNamespace()) - .withLabel("app.kubernetes.io/instance", release.getName()) - .list() - .getItems() - .stream() - .map(pod -> { - Task currentTask = new Task(); - currentTask.setId(pod.getMetadata().getName()); - TaskStatus status = new TaskStatus(); - status.setStatus(pod.getStatus().getPhase()); - status.setReason( - pod.getStatus().getContainerStatuses().stream() - .filter(cstatus -> cstatus.getState().getWaiting() != null) - .map(cstatus -> cstatus.getState().getWaiting().getReason()) - .findFirst() - .orElse(null)); - pod.getStatus().getContainerStatuses().forEach(c -> { - Container container = new Container(); - container.setName(c.getName()); - container.setReady(c.getReady()); - currentTask.getContainers().add(container); - }); - currentTask.setStatus(status); - return currentTask; - }) - .toList()); - - return service; -} + try { + List urls = getServiceUrls(region, manifest, client); + service.setUrls(urls); + } catch (Exception e) { + LOGGER.warn( + "Failed to retrieve URLs for release {} in namespace {}. Region: {}, User: {}", + release.getName(), + release.getNamespace(), + region.getName(), + user.getIdep(), + e); + service.setUrls(List.of()); + } + + try { + List controllers = + checkHelmReleaseHealth(release.getNamespace(), manifest, client); + service.setControllers(controllers); + } catch (Exception e) { + LOGGER.warn( + "Failed to retrieve controllers for release {} in namespace {}. Region: {}, User: {}", + release.getName(), + release.getNamespace(), + region.getName(), + user.getIdep(), + e); + service.setControllers(List.of()); + } + + service.setInstances(1); + + service.setTasks( + client + .pods() + .inNamespace(release.getNamespace()) + .withLabel("app.kubernetes.io/instance", release.getName()) + .list() + .getItems() + .stream() + .map( + pod -> { + Task currentTask = new Task(); + currentTask.setId(pod.getMetadata().getName()); + TaskStatus status = new TaskStatus(); + status.setStatus(pod.getStatus().getPhase()); + status.setReason( + pod.getStatus().getContainerStatuses().stream() + .filter( + cstatus -> + cstatus.getState().getWaiting() + != null) + .map( + cstatus -> + cstatus.getState() + .getWaiting() + .getReason()) + .findFirst() + .orElse(null)); + pod.getStatus() + .getContainerStatuses() + .forEach( + c -> { + Container container = new Container(); + container.setName(c.getName()); + container.setReady(c.getReady()); + currentTask.getContainers().add(container); + }); + currentTask.setStatus(status); + return currentTask; + }) + .toList()); + + return service; + } } diff --git a/onyxia-api/src/test/java/fr/insee/onyxia/api/controller/RestExceptionHandlerTest.java b/onyxia-api/src/test/java/fr/insee/onyxia/api/controller/RestExceptionHandlerTest.java index 1fe2a9e6..d2b0029d 100644 --- a/onyxia-api/src/test/java/fr/insee/onyxia/api/controller/RestExceptionHandlerTest.java +++ b/onyxia-api/src/test/java/fr/insee/onyxia/api/controller/RestExceptionHandlerTest.java @@ -1,21 +1,20 @@ package fr.insee.onyxia.api.controller; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.http.HttpStatus; -import org.springframework.http.ProblemDetail; -import org.springframework.security.access.AccessDeniedException; -import org.everit.json.schema.Schema; -import org.everit.json.schema.ValidationException; - import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.when; +import jakarta.servlet.http.HttpServletRequest; import java.net.URI; import java.util.List; +import org.everit.json.schema.Schema; +import org.everit.json.schema.ValidationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.security.access.AccessDeniedException; class RestExceptionHandlerTest { @@ -32,8 +31,10 @@ void setUp() { void testHandleAccessDeniedForNamespaceCreation() { when(mockRequest.getRequestURI()).thenReturn("/onboarding"); - AccessDeniedException exception = new AccessDeniedException("Access Denied for Namespace Creation"); - ProblemDetail result = restExceptionHandler.handleAccessDeniedException(exception, mockRequest); + AccessDeniedException exception = + new AccessDeniedException("Access Denied for Namespace Creation"); + ProblemDetail result = + restExceptionHandler.handleAccessDeniedException(exception, mockRequest); assertEquals(HttpStatus.FORBIDDEN.value(), result.getStatus()); assertEquals(RestExceptionTypes.ACCESS_DENIED, result.getType()); @@ -49,7 +50,8 @@ void testHandleValidationException() { ValidationException validationException = Mockito.mock(ValidationException.class); when(validationException.getMessage()).thenReturn("Validation Error Message"); - ProblemDetail result = restExceptionHandler.handleValidationException(validationException, mockRequest); + ProblemDetail result = + restExceptionHandler.handleValidationException(validationException, mockRequest); assertEquals(HttpStatus.BAD_REQUEST.value(), result.getStatus()); assertEquals(RestExceptionTypes.VALIDATION_FAILED, result.getType()); @@ -62,17 +64,20 @@ void testHandleValidationException() { @Test void testHandleValidationExceptionWithErrors() { when(mockRequest.getRequestURI()).thenReturn("/my-lab/app"); - + Schema mockSchema = Mockito.mock(Schema.class); - ValidationException ex1 = new ValidationException(mockSchema, "Field 'name' is required","name"); - ValidationException ex2 = new ValidationException(mockSchema, "Field 'version' must be a number","version"); + ValidationException ex1 = + new ValidationException(mockSchema, "Field 'name' is required", "name"); + ValidationException ex2 = + new ValidationException(mockSchema, "Field 'version' must be a number", "version"); ValidationException validationException = Mockito.mock(ValidationException.class); when(validationException.getMessage()).thenReturn("Validation Error Message"); when(validationException.getCausingExceptions()).thenReturn(List.of(ex1, ex2)); - ProblemDetail result = restExceptionHandler.handleValidationException(validationException, mockRequest); + ProblemDetail result = + restExceptionHandler.handleValidationException(validationException, mockRequest); assertEquals(HttpStatus.BAD_REQUEST.value(), result.getStatus()); assertEquals(RestExceptionTypes.VALIDATION_FAILED, result.getType()); @@ -82,8 +87,8 @@ void testHandleValidationExceptionWithErrors() { @SuppressWarnings("unchecked") List errors = (List) result.getProperties().get("errors"); - assertThat(errors).containsExactly( - "#: Field 'name' is required", - "#: Field 'version' must be a number"); + assertThat(errors) + .containsExactly( + "#: Field 'name' is required", "#: Field 'version' must be a number"); } }