diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 5d564f02..05aaa68b 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -1009,6 +1009,12 @@ paths: "*/*": schema: "$ref": "#/components/schemas/EntityGraphFlatDtoOut" + '400': + description: Invalid graph query parameter value + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" '404': description: Entity not found with the provided identifier content: diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 213d51a6..fbb27c69 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -235,6 +235,7 @@ public class SwaggerDescription { public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY = "Get entity relationship graph as flat nodes and edges"; public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION = "Retrieves the entity relationship graph as a flat nodes-and-edges structure, suitable for frontend visualization tools such as React Flow, Vis.js, and Cytoscape."; public static final String RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS = "Flat entity graph successfully retrieved"; + public static final String RESPONSE_INVALID_GRAPH_QUERY = "Invalid graph query parameter value"; public static final String ENTITY_GRAPH_FLAT_NODES_DESCRIPTION = "All entity nodes in the graph"; public static final String ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION = "All directed relation edges in the graph"; public static final String ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION = "Unique node identifier composed of templateIdentifier:identifier"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 17e896fe..daf48385 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -1,5 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.BAD_REQUEST_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; @@ -11,6 +12,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_TRAVERSAL_MODE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_GRAPH_QUERY; import static org.springframework.http.HttpStatus.OK; import java.util.List; @@ -77,6 +79,7 @@ public class EntityGraphController { @ResponseStatus(OK) @Operation(summary = ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY, description = ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION, responses = { @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS, content = @Content(schema = @Schema(implementation = EntityGraphFlatDtoOut.class))), + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_GRAPH_QUERY, content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}) public EntityGraphFlatDtoOut getEntityGraph(@PathVariable @NotBlank String templateIdentifier, @PathVariable @NotBlank String entityIdentifier, diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 7627cd64..697d0dc5 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -2,6 +2,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.regex.Matcher; @@ -20,6 +21,7 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.NoHandlerFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; @@ -296,6 +298,22 @@ public ResponseEntity handleMethodArgumentNotValidException( return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); } + /// Handles invalid request parameter types, including unknown enum values. + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException ex) { + String errorMessage = "Invalid value '%s' for parameter '%s'.".formatted(ex.getValue(), + ex.getName()); + Class requiredType = ex.getRequiredType(); + if (requiredType != null && requiredType.isEnum()) { + errorMessage += " Allowed values: " + Arrays.toString(requiredType.getEnumConstants()) + "."; + } else if (requiredType != null) { + errorMessage += " Expected type: " + requiredType.getSimpleName() + "."; + } + log.warn("Request parameter type mismatch: {}", errorMessage); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + /// Handles JSON parsing and deserialization errors from request bodies. /// /// **User-friendly parsing:** Converts technical JSON parsing errors into @@ -606,7 +624,6 @@ public ResponseEntity handleEntityDynamicMappingNotFoundException } /// Handles creation of a dynamic mapping whose identifier already exists. - /// /// HTTP mapping: Maps EntityDynamicMappingAlreadyExistsException to HTTP 409 /// Conflict, surfacing the uniqueness violation with business meaning. @ExceptionHandler(EntityDynamicMappingAlreadyExistsException.class) diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index f29c7213..5d0b0ee2 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -2,6 +2,8 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.lang.reflect.Constructor; @@ -17,14 +19,18 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.core.MethodParameter; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.test.web.servlet.client.RestTestClient; import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; @@ -35,6 +41,10 @@ import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException; import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException; import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphQueryOptions; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode; +import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; +import com.decathlon.idp_core.infrastructure.adapters.api.controller.EntityGraphController; import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; /// Comprehensive unit tests for [ApiExceptionHandler]. @@ -55,6 +65,87 @@ void setUp() throws Exception { exceptionHandler = constructor.newInstance(); } + @Nested + @DisplayName("Request parameter conversion") + class RequestParameterConversionTests { + + private EntityGraphService graphService; + private RestTestClient client; + + @BeforeEach + void setUpClient() { + graphService = mock(EntityGraphService.class); + client = RestTestClient.bindToController(new EntityGraphController(graphService)) + .configureServer(builder -> builder.setControllerAdvice(exceptionHandler)).build(); + } + + @ParameterizedTest + @CsvSource({"traversal_mode, OUTBOUND_ONL", "traversal_mode, UNKNOWN", + "traversal_mode, outbound_only", "depth, abc", "include_data, invalid"}) + @DisplayName("Should return 400 with a description for invalid graph parameters") + void shouldRejectInvalidGraphParameters(String parameter, String value) { + client.get().uri("/api/v1/entities/product/test/graph?{parameter}={value}", parameter, value) + .exchange().expectStatus().isBadRequest().expectBody(ErrorResponse.class).value(body -> { + assertEquals("BAD_REQUEST", body.getError()); + assertTrue(body.getErrorDescription().contains(parameter)); + assertTrue(body.getErrorDescription().contains(value)); + if ("traversal_mode".equals(parameter)) { + assertTrue(body.getErrorDescription() + .contains("Allowed values: [DIRECT_LINEAGE, BIDIRECTIONAL, OUTBOUND_ONLY].")); + } + }); + verifyNoInteractions(graphService); + } + + @ParameterizedTest + @EnumSource(EntityGraphTraversalMode.class) + @DisplayName("Should continue accepting all supported traversal modes") + void shouldAcceptSupportedTraversalModes(EntityGraphTraversalMode mode) { + client.get().uri("/api/v1/entities/product/test/graph?traversal_mode={mode}", mode).exchange() + .expectStatus().isOk(); + + verify(graphService).getEntityGraph("product", "test", + new EntityGraphQueryOptions(1, false, false, Set.of(), Set.of(), mode)); + } + + @Test + @DisplayName("Should preserve the default traversal mode") + void shouldPreserveDefaultTraversalMode() { + client.get().uri("/api/v1/entities/product/test/graph").exchange().expectStatus().isOk(); + + verify(graphService).getEntityGraph("product", "test", new EntityGraphQueryOptions(1, false, + false, Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE)); + } + + @Test + @DisplayName("Should describe the expected type for non-enum parameters") + void shouldDescribeExpectedType() { + var exception = new MethodArgumentTypeMismatchException("abc", int.class, "depth", + mock(MethodParameter.class), new IllegalArgumentException()); + + var response = exceptionHandler.handleMethodArgumentTypeMismatchException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals("Invalid value 'abc' for parameter 'depth'. Expected type: int.", + response.getBody().getErrorDescription()); + } + + @Test + @DisplayName("Should handle parameter type mismatches without a required type") + void shouldHandleUnknownRequiredType() { + var exception = new MethodArgumentTypeMismatchException("invalid", null, "parameter", + mock(MethodParameter.class), new IllegalArgumentException()); + + var response = exceptionHandler.handleMethodArgumentTypeMismatchException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals("Invalid value 'invalid' for parameter 'parameter'.", + response.getBody().getErrorDescription()); + } + } + @Nested @DisplayName("Domain Exception Handling") class DomainExceptionTests {