diff --git a/build.gradle b/build.gradle index 6826c4b..c8137b1 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ plugins { } group 'ee.bitweb' -version '3.3.0' +version '3.4.0' java { sourceCompatibility = '17' } diff --git a/src/main/java/ee/bitweb/core/api/ControllerAdvisor.java b/src/main/java/ee/bitweb/core/api/ControllerAdvisor.java index 9e9e842..ea3e7e5 100644 --- a/src/main/java/ee/bitweb/core/api/ControllerAdvisor.java +++ b/src/main/java/ee/bitweb/core/api/ControllerAdvisor.java @@ -13,7 +13,6 @@ import ee.bitweb.core.api.model.exception.GenericErrorResponse; import ee.bitweb.core.api.model.exception.PersistenceErrorResponse; import ee.bitweb.core.api.model.exception.ValidationErrorResponse; -import ee.bitweb.core.exception.persistence.PersistenceException; import ee.bitweb.core.exception.validation.InvalidFormatValidationException; import com.fasterxml.jackson.databind.exc.InvalidFormatException; @@ -164,7 +163,7 @@ public ValidationErrorResponse handleException( log(properties.getLogging().getConstraintViolationException(), e.getMessage(), e); return logAndReturn( - new ValidationErrorResponse(getResponseId(), ExceptionConverter.convert(e)) + new ValidationErrorResponse(getResponseId(), ExceptionConverter.convert(e, properties.isShowDetailedFieldNames())) ); } diff --git a/src/main/java/ee/bitweb/core/api/ControllerAdvisorProperties.java b/src/main/java/ee/bitweb/core/api/ControllerAdvisorProperties.java index 47c26a5..78cbc9e 100644 --- a/src/main/java/ee/bitweb/core/api/ControllerAdvisorProperties.java +++ b/src/main/java/ee/bitweb/core/api/ControllerAdvisorProperties.java @@ -23,6 +23,7 @@ public class ControllerAdvisorProperties { static final String PREFIX = "ee.bitweb.core.controller-advice"; private boolean autoConfiguration = false; + private boolean showDetailedFieldNames = false; @Valid private Logging logging = new Logging(); diff --git a/src/main/java/ee/bitweb/core/api/ExceptionConverter.java b/src/main/java/ee/bitweb/core/api/ExceptionConverter.java index 9122d24..705e48c 100644 --- a/src/main/java/ee/bitweb/core/api/ExceptionConverter.java +++ b/src/main/java/ee/bitweb/core/api/ExceptionConverter.java @@ -8,7 +8,6 @@ import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; -import org.springframework.validation.beanvalidation.SpringValidatorAdapter; import java.util.HashSet; import java.util.Set; @@ -20,11 +19,15 @@ public class ExceptionConverter { public static final String CONSTRAINT_VIOLATION_MESSAGE = "CONSTRAINT_VIOLATION"; public static ValidationException convert(ConstraintViolationException e) { + return convert(e, false); + } + + public static ValidationException convert(ConstraintViolationException e, boolean showDetailedFieldNames) { Set fieldErrors = e .getConstraintViolations() .stream() .map(error -> new FieldError( - getFieldName(error), + getFieldName(error, showDetailedFieldNames), getValidatorName(error), error.getMessage() )) @@ -40,21 +43,23 @@ public static ValidationException translateBindingResult(BindingResult bindingRe error.getField(), error.getCodes() != null ? error.getCodes()[0].split("\\.")[0] : null, parseMessage(error) - )).collect(Collectors.toList())); + )).toList()); fieldErrors.addAll(bindingResult.getGlobalErrors().stream().map(error -> new FieldError( error.getObjectName(), error.getCodes() != null ? error.getCodes()[0].split("\\.")[0] : null, error.getDefaultMessage() - )).collect(Collectors.toList())); + )).toList()); return new ValidationException(ErrorMessage.INVALID_ARGUMENT.toString(), fieldErrors); } - private static String getFieldName(ConstraintViolation error) { - String[] parts = error.getPropertyPath().toString().split("\\."); + private static String getFieldName(ConstraintViolation error, boolean showDetailedFieldNames) { + if (showDetailedFieldNames) { + return FieldNameResolver.resolve(error); + } - return parts[parts.length - 1]; + return FieldNameResolver.resolveWithRegex(error); } private static String getValidatorName(ConstraintViolation constraintViolation) { diff --git a/src/main/java/ee/bitweb/core/api/FieldNameResolver.java b/src/main/java/ee/bitweb/core/api/FieldNameResolver.java new file mode 100644 index 0000000..07878b3 --- /dev/null +++ b/src/main/java/ee/bitweb/core/api/FieldNameResolver.java @@ -0,0 +1,60 @@ +package ee.bitweb.core.api; + + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ElementKind; +import jakarta.validation.Path; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.hibernate.validator.internal.engine.ConstraintViolationImpl; +import org.hibernate.validator.internal.engine.path.NodeImpl; +import org.hibernate.validator.internal.engine.path.PathImpl; + +import java.util.EnumSet; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class FieldNameResolver { + + private static final String INDEX_OPEN = "["; + private static final String INDEX_CLOSE = "]"; + private static final String FIELD_NAME_DELIMITER = "."; + private static final EnumSet IGNORED_ELEMENTS = EnumSet.of(ElementKind.METHOD, ElementKind.PARAMETER); + + public static String resolve(ConstraintViolation error) { + if (error instanceof ConstraintViolationImpl violationImpl + && violationImpl.getPropertyPath() instanceof PathImpl pathImpl) { + + return resolveFieldName(pathImpl); + } + + return resolveWithRegex(error); + } + + private static String resolveFieldName(PathImpl path) { + StringBuilder builder = new StringBuilder(); + for (Path.Node node : path) { + if (!(node instanceof NodeImpl nodeImpl) || IGNORED_ELEMENTS.contains(node.getKind())) { + continue; + } + + if (nodeImpl.isInIterable()) { + builder.append(INDEX_OPEN); + builder.append(nodeImpl.getIndex()); + builder.append(INDEX_CLOSE); + } + if (!builder.isEmpty()) { + builder.append(FIELD_NAME_DELIMITER); + } + builder.append(nodeImpl.getName()); + } + + return builder.toString(); + } + + public static String resolveWithRegex(ConstraintViolation error) { + String[] parts = error.getPropertyPath().toString().split("\\."); + + return parts[parts.length - 1]; + } + +} diff --git a/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorCompleteFileNamesIntegrationTests.java b/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorCompleteFileNamesIntegrationTests.java new file mode 100644 index 0000000..23cc243 --- /dev/null +++ b/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorCompleteFileNamesIntegrationTests.java @@ -0,0 +1,157 @@ +package ee.bitweb.core.api.model.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import ee.bitweb.core.TestSpringApplication; +import ee.bitweb.core.api.testcomponents.TestPingController; +import ee.bitweb.http.api.response.Error; +import ee.bitweb.http.api.response.ResponseAssertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.util.List; + +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +@Tag("integration") +@AutoConfigureMockMvc +@ActiveProfiles("MockedInvokerTraceIdCreator") +@SpringBootTest( + classes = TestSpringApplication.class, + properties = { + "ee.bitweb.core.trace.auto-configuration=true", + "ee.bitweb.core.controller-advice.auto-configuration=true", + "ee.bitweb.core.controller-advice.show-detailed-field-names=true" + } +) +class ControllerAdvisorCompleteFileNamesIntegrationTests { + + private static final String TRACE_ID_HEADER_NAME = "X-Trace-ID"; + + @Autowired + private ObjectMapper mapper; + + @Autowired + private MockMvc mockMvc; + + @Test + void onConstraintViolationExceptionInCodeShouldReturnBadRequestError() throws Exception { + MockHttpServletRequestBuilder mockMvcBuilder = get(TestPingController.BASE_URL + "/constraint-violation") + .header(TRACE_ID_HEADER_NAME, "1234567890"); + + ResultActions result = mockMvc.perform(mockMvcBuilder).andDo(print()); + ResponseAssertions.assertConstraintViolationErrorResponse( + result, + List.of( + Error.notBlank("simpleProperty"), + Error.notNull("simpleProperty") + ) + ); + assertIdField(result); + } + + @Test + void onConstraintViolationExceptionShouldReturnBadRequestError() throws Exception { + TestPingController.ComplexValidatedObject data = new TestPingController.ComplexValidatedObject(); + data.setNestedObject(new TestPingController.SimpleValidatedObject()); + + MockHttpServletRequestBuilder mockMvcBuilder = post(TestPingController.BASE_URL + "/validated") + .header(TRACE_ID_HEADER_NAME, "1234567890") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(mapper.writeValueAsString(data)); + + ResultActions result = mockMvc.perform(mockMvcBuilder).andDo(print()); + ResponseAssertions.assertValidationErrorResponse( + result, + List.of( + Error.notBlank("complexProperty"), + Error.notNull("complexProperty"), + Error.notBlank("nestedObject.simpleProperty"), + Error.notNull("nestedObject.simpleProperty"), + Error.notEmpty("objects"), + Error.notNull("objects") + ) + ); + assertIdField(result); + } + + @Test + void onConstraintViolationExceptionInNestedObjectShouldReturnBadRequestError() throws Exception { + TestPingController.ComplexValidatedObject data = new TestPingController.ComplexValidatedObject(); + data.setNestedObject(new TestPingController.SimpleValidatedObject()); + data.setObjects(List.of(new TestPingController.SimpleValidatedObject())); + + MockHttpServletRequestBuilder mockMvcBuilder = post(TestPingController.BASE_URL + "/validated") + .header(TRACE_ID_HEADER_NAME, "1234567890") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(mapper.writeValueAsString(data)); + + ResultActions result = mockMvc.perform(mockMvcBuilder).andDo(print()); + ResponseAssertions.assertValidationErrorResponse( + result, + List.of( + Error.notBlank("complexProperty"), + Error.notNull("complexProperty"), + Error.notBlank("nestedObject.simpleProperty"), + Error.notNull("nestedObject.simpleProperty"), + Error.notBlank("objects[0].simpleProperty"), + Error.notNull("objects[0].simpleProperty") + ) + ); + assertIdField(result); + } + + @Test + void onConstraintViolationExceptionInListObjectShouldReturnBadRequestError() throws Exception { + TestPingController.SimpleValidatedObject firstValidSimpleObject = new TestPingController.SimpleValidatedObject(); + firstValidSimpleObject.setSimpleProperty("property"); + + TestPingController.ComplexValidatedObject complexObject = new TestPingController.ComplexValidatedObject(); + complexObject.setNestedObject(new TestPingController.SimpleValidatedObject()); + complexObject.setObjects(List.of(firstValidSimpleObject, new TestPingController.SimpleValidatedObject())); + + List data = List.of( + new TestPingController.ComplexValidatedObject(), + complexObject + ); + + MockHttpServletRequestBuilder mockMvcBuilder = post(TestPingController.BASE_URL + "/validated-list") + .header(TRACE_ID_HEADER_NAME, "1234567890") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(mapper.writeValueAsString(data)); + + ResultActions result = mockMvc.perform(mockMvcBuilder).andDo(print()); + ResponseAssertions.assertConstraintViolationErrorResponse( + result, + List.of( + Error.notBlank("[0].complexProperty"), + Error.notNull("[0].complexProperty"), + Error.notNull("[0].nestedObject"), + Error.notEmpty("[0].objects"), + Error.notNull("[0].objects"), + Error.notBlank("[1].complexProperty"), + Error.notNull("[1].complexProperty"), + Error.notBlank("[1].nestedObject.simpleProperty"), + Error.notNull("[1].nestedObject.simpleProperty"), + Error.notBlank("[1].objects[1].simpleProperty"), + Error.notNull("[1].objects[1].simpleProperty") + ) + ); + assertIdField(result); + } + + private static void assertIdField(ResultActions actions) throws Exception { + actions.andExpect(jsonPath("$.id", is("1234567890_generated-trace-id"))); + } +} diff --git a/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorIntegrationTests.java b/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorIntegrationTests.java index 167dab5..2e97078 100644 --- a/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorIntegrationTests.java +++ b/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorIntegrationTests.java @@ -30,10 +30,11 @@ @AutoConfigureMockMvc @ActiveProfiles("MockedInvokerTraceIdCreator") @SpringBootTest( - classes= TestSpringApplication.class, + classes = TestSpringApplication.class, properties = { "ee.bitweb.core.trace.auto-configuration=true", - "ee.bitweb.core.controller-advice.auto-configuration=true" + "ee.bitweb.core.controller-advice.auto-configuration=true", + "ee.bitweb.core.controller-advice.show-detailed-field-names=false" } ) class ControllerAdvisorIntegrationTests { @@ -118,7 +119,7 @@ void onConflictExceptionShouldReturnConflictError() throws Exception { } @Test - void onConstraintViolationExceptionInCodeShoudReturnBadRequestError() throws Exception { + void onConstraintViolationExceptionInCodeShouldReturnBadRequestError() throws Exception { MockHttpServletRequestBuilder mockMvcBuilder = get(TestPingController.BASE_URL + "/constraint-violation") .header(TRACE_ID_HEADER_NAME, "1234567890"); @@ -136,6 +137,7 @@ void onConstraintViolationExceptionInCodeShoudReturnBadRequestError() throws Exc @Test void onConstraintViolationExceptionShouldReturnBadRequestError() throws Exception { TestPingController.ComplexValidatedObject data = new TestPingController.ComplexValidatedObject(); + data.setNestedObject(new TestPingController.SimpleValidatedObject()); MockHttpServletRequestBuilder mockMvcBuilder = post(TestPingController.BASE_URL + "/validated") .header(TRACE_ID_HEADER_NAME, "1234567890") @@ -148,6 +150,8 @@ void onConstraintViolationExceptionShouldReturnBadRequestError() throws Exceptio List.of( Error.notBlank("complexProperty"), Error.notNull("complexProperty"), + Error.notBlank("nestedObject.simpleProperty"), + Error.notNull("nestedObject.simpleProperty"), Error.notEmpty("objects"), Error.notNull("objects") ) @@ -158,6 +162,7 @@ void onConstraintViolationExceptionShouldReturnBadRequestError() throws Exceptio @Test void onConstraintViolationExceptionInNestedObjectShouldReturnBadRequestError() throws Exception { TestPingController.ComplexValidatedObject data = new TestPingController.ComplexValidatedObject(); + data.setNestedObject(new TestPingController.SimpleValidatedObject()); data.setObjects(List.of(new TestPingController.SimpleValidatedObject())); MockHttpServletRequestBuilder mockMvcBuilder = post(TestPingController.BASE_URL + "/validated") @@ -171,6 +176,8 @@ void onConstraintViolationExceptionInNestedObjectShouldReturnBadRequestError() t List.of( Error.notBlank("complexProperty"), Error.notNull("complexProperty"), + Error.notBlank("nestedObject.simpleProperty"), + Error.notNull("nestedObject.simpleProperty"), Error.notBlank("objects[0].simpleProperty"), Error.notNull("objects[0].simpleProperty") ) @@ -178,6 +185,41 @@ void onConstraintViolationExceptionInNestedObjectShouldReturnBadRequestError() t assertIdField(result); } + @Test + void onConstraintViolationExceptionInListObjectShouldReturnBadRequestError() throws Exception { + TestPingController.SimpleValidatedObject firstValidSimpleObject = new TestPingController.SimpleValidatedObject(); + firstValidSimpleObject.setSimpleProperty("property"); + + TestPingController.ComplexValidatedObject complexObject = new TestPingController.ComplexValidatedObject(); + complexObject.setNestedObject(new TestPingController.SimpleValidatedObject()); + complexObject.setObjects(List.of(firstValidSimpleObject, new TestPingController.SimpleValidatedObject())); + + List data = List.of( + new TestPingController.ComplexValidatedObject(), + complexObject + ); + + MockHttpServletRequestBuilder mockMvcBuilder = post(TestPingController.BASE_URL + "/validated-list") + .header(TRACE_ID_HEADER_NAME, "1234567890") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(mapper.writeValueAsString(data)); + + ResultActions result = mockMvc.perform(mockMvcBuilder).andDo(print()); + ResponseAssertions.assertConstraintViolationErrorResponse( + result, + List.of( + Error.notBlank("complexProperty"), + Error.notNull("complexProperty"), + Error.notNull("nestedObject"), + Error.notEmpty("objects"), + Error.notNull("objects"), + Error.notBlank("simpleProperty"), + Error.notNull("simpleProperty") + ) + ); + assertIdField(result); + } + @Test void onContentTypeMismatchShouldReturnBadRequest() throws Exception { TestPingController.ComplexValidatedObject data = new TestPingController.ComplexValidatedObject(); @@ -195,12 +237,9 @@ void onContentTypeMismatchShouldReturnBadRequest() throws Exception { } @Test - void onConstraintViolationExceptionWithinNestedObjectInGetRequestShouldReturnBadRequestError() throws Exception { - TestPingController.ComplexValidatedObject data = new TestPingController.ComplexValidatedObject(); - + void onMethodArgumentNotValidExceptionWithinSimpleObjectInGetRequestShouldReturnBadRequestError() throws Exception { MockHttpServletRequestBuilder mockMvcBuilder = get(TestPingController.BASE_URL + "/validated") - .header(TRACE_ID_HEADER_NAME, "1234567890") - .content(mapper.writeValueAsString(data)); + .header(TRACE_ID_HEADER_NAME, "1234567890"); ResultActions result = mockMvc.perform(mockMvcBuilder).andDo(print()); ResponseAssertions.assertValidationErrorResponse( @@ -237,7 +276,7 @@ void onInvalidContentTypeWithMultipartRequestShouldReturnBadRequestError() throw } @Test - void onMissingMultipartRequestPartShouldReturnBadRequestError () throws Exception { + void onMissingMultipartRequestPartShouldReturnBadRequestError() throws Exception { MockHttpServletRequestBuilder mockMvcBuilder = multipart(TestPingController.BASE_URL + "/import") .header(TRACE_ID_HEADER_NAME, "1234567890"); @@ -371,7 +410,7 @@ void onGenericClientAbortException() throws Exception { } @Test - void testDateFieldBindException() throws Exception { + void testDateFieldMethodArgumentNotValidException() throws Exception { MockHttpServletRequestBuilder mockMvcBuilder = get(TestPingController.BASE_URL + "/complex-data") .header(TRACE_ID_HEADER_NAME, "1234567890") .contentType(MediaType.APPLICATION_JSON) diff --git a/src/test/java/ee/bitweb/core/api/testcomponents/TestPingController.java b/src/test/java/ee/bitweb/core/api/testcomponents/TestPingController.java index 7b9f47e..b4edd0e 100644 --- a/src/test/java/ee/bitweb/core/api/testcomponents/TestPingController.java +++ b/src/test/java/ee/bitweb/core/api/testcomponents/TestPingController.java @@ -54,6 +54,9 @@ public void postValidated(@Valid @RequestBody ComplexValidatedObject data) { @GetMapping("/validated") public void getValidated(@Valid SimpleValidatedObject data) {} + @PostMapping("/validated-list") + public void postValidatedList(@RequestBody List<@Valid ComplexValidatedObject> data) {} + @GetMapping("/complex-data") public void dateFieldParam(ComplexData data) {} @@ -121,6 +124,10 @@ public static class ComplexValidatedObject { @NotBlank private String complexProperty; + @Valid + @NotNull + private SimpleValidatedObject nestedObject; + @Valid @NotNull @NotEmpty