diff --git a/pom.xml b/pom.xml index 1fd0c668..a2e29249 100644 --- a/pom.xml +++ b/pom.xml @@ -88,6 +88,13 @@ + + + com.powsybl + powsybl-ws-commons + 1.30.0 + + org.gridsuite diff --git a/src/main/java/org/gridsuite/filter/server/FilterService.java b/src/main/java/org/gridsuite/filter/server/FilterService.java index 8ec2e400..3ff37d90 100644 --- a/src/main/java/org/gridsuite/filter/server/FilterService.java +++ b/src/main/java/org/gridsuite/filter/server/FilterService.java @@ -17,6 +17,7 @@ import org.gridsuite.filter.FilterLoader; import org.gridsuite.filter.IFilterAttributes; import org.gridsuite.filter.expertfilter.ExpertFilter; +import org.gridsuite.filter.exceptions.FilterCycleException; import org.gridsuite.filter.identifierlistfilter.FilterEquipments; import org.gridsuite.filter.identifierlistfilter.FilteredIdentifiables; import org.gridsuite.filter.identifierlistfilter.IdentifiableAttributes; @@ -24,6 +25,8 @@ import org.gridsuite.filter.server.dto.FilterAttributes; import org.gridsuite.filter.server.dto.FiltersWithEquipmentTypes; import org.gridsuite.filter.server.dto.IdsByGroup; +import org.gridsuite.filter.server.error.FilterBusinessErrorCode; +import org.gridsuite.filter.server.error.FilterException; import org.gridsuite.filter.server.repositories.proxies.AbstractFilterRepositoryProxy; import org.gridsuite.filter.server.service.DirectoryService; import org.gridsuite.filter.server.utils.FilterWithEquipmentTypesUtils; @@ -61,7 +64,7 @@ public class FilterService { public List getFilters() { return this.repositoriesService.getFiltersAttributes() - .map(IFilterAttributes.class::cast) // cast because generics are invariants + .map(IFilterAttributes.class::cast) // cast because generics are invariants .toList(); } @@ -109,7 +112,7 @@ public List createFilters(List filters) { } Map, List> repositoryFiltersMap = filters.stream() - .collect(Collectors.groupingBy(this.repositoriesService::getRepositoryFromType)); + .collect(Collectors.groupingBy(this.repositoriesService::getRepositoryFromType)); List createdFilters = new ArrayList<>(); repositoryFiltersMap.forEach((repository, subFilters) -> createdFilters.addAll(repository.insertAll(subFilters))); @@ -150,7 +153,7 @@ public Map duplicateFilters(List filterUuids) { }); Map, List> repositoryFiltersMap = sourceFilters.stream() - .collect(Collectors.groupingBy(this.repositoriesService::getRepositoryFromType)); + .collect(Collectors.groupingBy(this.repositoriesService::getRepositoryFromType)); repositoryFiltersMap.forEach(AbstractFilterRepositoryProxy::insertAll); @@ -170,7 +173,11 @@ private AbstractFilter doUpdateFilter(UUID id, AbstractFilter newFilter, String FilterLoader filterLoader = uuids -> uuids.stream() .map(uuid -> uuid.equals(id) ? newFilter : this.repositoriesService.getFilter(uuid).orElse(null)) .toList(); - FilterCycleDetector.checkNoCycle(newFilter, filterLoader); + try { + FilterCycleDetector.checkNoCycle(newFilter, filterLoader); + } catch (FilterCycleException exception) { + throw new FilterException(FilterBusinessErrorCode.FILTER_CYCLE_DETECTED, exception.getMessage()); + } AbstractFilter modifiedOrCreatedFilter; if (filterOpt.get().getType() == newFilter.getType()) { // filter type has not changed @@ -301,13 +308,13 @@ public Map getIdentifiablesCountByGroup(IdsByGroup idsByGroup, UUI Objects.requireNonNull(idsByGroup); final FilterLoader filterLoader = this.repositoriesService.getFilterLoader(); return idsByGroup.getIds().entrySet().stream() - .collect(Collectors.toMap( - Map.Entry::getKey, - entry -> this.repositoriesService.getFilters(entry.getValue()).stream() - .mapToLong(f -> getIdentifiableAttributes(f, networkUuid, variantId, filterLoader).size()) - .sum() - ) - ); + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> this.repositoriesService.getFilters(entry.getValue()).stream() + .mapToLong(f -> getIdentifiableAttributes(f, networkUuid, variantId, filterLoader).size()) + .sum() + ) + ); } @Transactional(readOnly = true) diff --git a/src/main/java/org/gridsuite/filter/server/PropertyServerNameProvider.java b/src/main/java/org/gridsuite/filter/server/PropertyServerNameProvider.java new file mode 100644 index 00000000..9dbb5540 --- /dev/null +++ b/src/main/java/org/gridsuite/filter/server/PropertyServerNameProvider.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.filter.server; + +import com.powsybl.ws.commons.error.ServerNameProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * @author Mohamed Ben-rejeb {@literal } + */ +@Component +public class PropertyServerNameProvider implements ServerNameProvider { + + private final String name; + + public PropertyServerNameProvider(@Value("${spring.application.name:filter-server}") String name) { + this.name = name; + } + + @Override + public String serverName() { + return name; + } +} diff --git a/src/main/java/org/gridsuite/filter/server/error/FilterBusinessErrorCode.java b/src/main/java/org/gridsuite/filter/server/error/FilterBusinessErrorCode.java new file mode 100644 index 00000000..c8501e43 --- /dev/null +++ b/src/main/java/org/gridsuite/filter/server/error/FilterBusinessErrorCode.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.filter.server.error; + +import com.powsybl.ws.commons.error.BusinessErrorCode; + +/** + * @author Mohamed Ben-rejeb {@literal } + * + * Business error codes emitted by the filter service. + */ +public enum FilterBusinessErrorCode implements BusinessErrorCode { + FILTER_CYCLE_DETECTED("filter.filterCycleDetected"); + private final String code; + + FilterBusinessErrorCode(String code) { + this.code = code; + } + + public String value() { + return code; + } +} diff --git a/src/main/java/org/gridsuite/filter/server/error/FilterException.java b/src/main/java/org/gridsuite/filter/server/error/FilterException.java new file mode 100644 index 00000000..c4e8ae17 --- /dev/null +++ b/src/main/java/org/gridsuite/filter/server/error/FilterException.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.filter.server.error; + +import com.powsybl.ws.commons.error.AbstractBusinessException; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +/** + * /** + * @author Mohamed Ben-rejeb {@literal } + * + * Filter server specific runtime exception enriched with a business error code. + */ +public class FilterException extends AbstractBusinessException { + + private final FilterBusinessErrorCode errorCode; + + public FilterException(FilterBusinessErrorCode errorCode, String message) { + super(Objects.requireNonNull(message, "message must not be null")); + this.errorCode = Objects.requireNonNull(errorCode, "errorCode must not be null"); + } + + @NotNull + @Override + public FilterBusinessErrorCode getBusinessErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/org/gridsuite/filter/server/error/RestResponseEntityExceptionHandler.java b/src/main/java/org/gridsuite/filter/server/error/RestResponseEntityExceptionHandler.java new file mode 100644 index 00000000..83c1a719 --- /dev/null +++ b/src/main/java/org/gridsuite/filter/server/error/RestResponseEntityExceptionHandler.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.filter.server.error; + +import com.powsybl.ws.commons.error.AbstractBaseRestExceptionHandler; +import com.powsybl.ws.commons.error.ServerNameProvider; +import org.jetbrains.annotations.NotNull; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ControllerAdvice; + +/** + * @author Mohamed Ben-rejeb {@literal } + */ +@ControllerAdvice +public class RestResponseEntityExceptionHandler + extends AbstractBaseRestExceptionHandler { + + public RestResponseEntityExceptionHandler(ServerNameProvider serverNameProvider) { + super(serverNameProvider); + } + + @NotNull + @Override + protected FilterBusinessErrorCode getBusinessCode(FilterException ex) { + return ex.getBusinessErrorCode(); + } + + @Override + protected HttpStatus mapStatus(FilterBusinessErrorCode code) { + return switch (code) { + case FILTER_CYCLE_DETECTED -> HttpStatus.BAD_REQUEST; + }; + } +} diff --git a/src/test/java/org/gridsuite/filter/server/FilterBusinessErrorCodeTest.java b/src/test/java/org/gridsuite/filter/server/FilterBusinessErrorCodeTest.java new file mode 100644 index 00000000..f2b2b50c --- /dev/null +++ b/src/test/java/org/gridsuite/filter/server/FilterBusinessErrorCodeTest.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.filter.server; + +import org.gridsuite.filter.server.error.FilterBusinessErrorCode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Mohamed Ben-rejeb {@literal } + */ +class FilterBusinessErrorCodeTest { + + @ParameterizedTest + @EnumSource(FilterBusinessErrorCode.class) + void valueStartsWithFilter(FilterBusinessErrorCode code) { + assertThat(code.value()).startsWith("filter."); + } +} diff --git a/src/test/java/org/gridsuite/filter/server/FilterEntityControllerTest.java b/src/test/java/org/gridsuite/filter/server/FilterEntityControllerTest.java index 4ec4e518..8dbdd4dc 100644 --- a/src/test/java/org/gridsuite/filter/server/FilterEntityControllerTest.java +++ b/src/test/java/org/gridsuite/filter/server/FilterEntityControllerTest.java @@ -870,7 +870,7 @@ private void updateFiltersWithNoneExistingId(Map filtersTo mvc.perform(put(URL_TEMPLATE + "/batch") .content(objectMapper.writeValueAsString(filtersToUpdateMap)) .contentType(APPLICATION_JSON)) - .andExpect(status().isNotFound()); + .andExpect(status().isInternalServerError()); } private void deleteFilter(UUID filterId) throws Exception { diff --git a/src/test/java/org/gridsuite/filter/server/FilterExceptionTest.java b/src/test/java/org/gridsuite/filter/server/FilterExceptionTest.java new file mode 100644 index 00000000..ee26e53b --- /dev/null +++ b/src/test/java/org/gridsuite/filter/server/FilterExceptionTest.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.filter.server; + +import org.gridsuite.filter.server.error.FilterBusinessErrorCode; +import org.gridsuite.filter.server.error.FilterException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Mohamed Ben-rejeb {@literal } + */ +class FilterExceptionTest { + + @Test + void defaultConstructorStoresMessage() { + FilterException exception = new FilterException(FilterBusinessErrorCode.FILTER_CYCLE_DETECTED, "cycle"); + assertThat(exception.getMessage()).isEqualTo("cycle"); + } +} diff --git a/src/test/java/org/gridsuite/filter/server/PropertyServerNameProviderTest.java b/src/test/java/org/gridsuite/filter/server/PropertyServerNameProviderTest.java new file mode 100644 index 00000000..4b0d6997 --- /dev/null +++ b/src/test/java/org/gridsuite/filter/server/PropertyServerNameProviderTest.java @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.filter.server; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Mohamed Ben-rejeb {@literal } + */ +class PropertyServerNameProviderTest { + + @Test + void returnsConfiguredName() { + PropertyServerNameProvider provider = new PropertyServerNameProvider("filter-overridden"); + assertThat(provider.serverName()).isEqualTo("filter-overridden"); + } +} diff --git a/src/test/java/org/gridsuite/filter/server/RestResponseEntityExceptionHandlerTest.java b/src/test/java/org/gridsuite/filter/server/RestResponseEntityExceptionHandlerTest.java new file mode 100644 index 00000000..a4c52989 --- /dev/null +++ b/src/test/java/org/gridsuite/filter/server/RestResponseEntityExceptionHandlerTest.java @@ -0,0 +1,135 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.filter.server; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.powsybl.ws.commons.error.PowsyblWsProblemDetail; +import org.gridsuite.filter.server.error.FilterBusinessErrorCode; +import org.gridsuite.filter.server.error.FilterException; +import org.gridsuite.filter.server.error.RestResponseEntityExceptionHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.client.HttpClientErrorException; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * @author Mohamed Ben-rejeb {@literal } + */ +class RestResponseEntityExceptionHandlerTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().registerModule(new JavaTimeModule()); + + private TestRestResponseEntityExceptionHandler handler; + + @BeforeEach + void setUp() { + handler = new TestRestResponseEntityExceptionHandler(); + } + + @Test + void mapsBusinessErrorToStatus() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/filters"); + FilterException exception = new FilterException(FilterBusinessErrorCode.FILTER_CYCLE_DETECTED, "cycle"); + + ResponseEntity response = handler.invokeHandleDomainException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertEquals("filter.filterCycleDetected", response.getBody().getBusinessErrorCode()); + } + + @Test + void propagatesRemoteErrorDetails() throws JsonProcessingException { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/filters/remote"); + PowsyblWsProblemDetail remote = PowsyblWsProblemDetail.builder(HttpStatus.INTERNAL_SERVER_ERROR) + .server("directory") + .businessErrorCode("directory.remoteError") + .detail("Directory failure") + .path("/directory") + .build(); + + HttpClientErrorException exception = HttpClientErrorException.create( + HttpStatus.INTERNAL_SERVER_ERROR, + "coucou", + HttpHeaders.EMPTY, + OBJECT_MAPPER.writeValueAsBytes(remote), + null + ); + + ResponseEntity response = handler.invokeHandleRemoteException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isNotNull(); + assertEquals("directory.remoteError", response.getBody().getBusinessErrorCode()); + assertThat(response.getBody().getChain()).hasSize(1); + } + + @Test + void wrapsInvalidRemotePayload() { + MockHttpServletRequest request = new MockHttpServletRequest("DELETE", "/filters/remote"); + HttpClientErrorException exception = HttpClientErrorException.create( + HttpStatus.BAD_GATEWAY, + "coucou", + null, + "oops".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8 + ); + + ResponseEntity response = handler.invokeHandleRemoteException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + assertThat(response.getBody()).isNotNull(); + assertNull(response.getBody().getBusinessErrorCode()); + } + + @Test + void keepsRemoteStatusWhenPayloadValid() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/filters/remote"); + PowsyblWsProblemDetail remote = PowsyblWsProblemDetail.builder(HttpStatus.BAD_GATEWAY) + .server("directory") + .businessErrorCode("directory.remoteError") + .detail("bad gateway") + .path("/directory") + .build(); + + byte[] payload = OBJECT_MAPPER.writeValueAsBytes(remote); + HttpClientErrorException exception = HttpClientErrorException.create(HttpStatus.BAD_GATEWAY, "bad gateway", + null, payload, StandardCharsets.UTF_8); + + ResponseEntity response = handler.invokeHandleRemoteException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getChain()).hasSize(1); + } + + private static final class TestRestResponseEntityExceptionHandler extends RestResponseEntityExceptionHandler { + + private TestRestResponseEntityExceptionHandler() { + super(() -> "filter-server"); + } + + ResponseEntity invokeHandleDomainException(FilterException exception, MockHttpServletRequest request) { + return super.handleDomainException(exception, request); + } + + ResponseEntity invokeHandleRemoteException(HttpClientErrorException exception, MockHttpServletRequest request) { + return super.handleRemoteException(exception, request); + } + } +}