build.gradle
@@ -55,6 +55,7 @@ implementation 'org.springframework.boot:spring-boot-starter-data-rest' implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'com.github.gavlyukovskiy:datasource-proxy-spring-boot-starter:1.8.1' implementation 'org.springdoc:springdoc-openapi-ui:1.6.11' implementation 'org.liquibase:liquibase-core' @@ -106,7 +107,7 @@ openapiProcessor { springRoot { processorName 'spring' processor 'io.openapiprocessor:openapi-processor-spring:2022.4' processor 'io.openapiprocessor:openapi-processor-spring:2022.5' apiPath "$projectDir/src/main/resources/api-definition.yaml" mapping "$projectDir/src/main/resources/api-mappings.yaml" targetDir "$projectDir/build/generated/sources/openapi" @@ -115,7 +116,7 @@ } springRbac { processorName 'spring' processor 'io.openapiprocessor:openapi-processor-spring:2022.4' processor 'io.openapiprocessor:openapi-processor-spring:2022.5' apiPath "$projectDir/src/main/resources/api-definition/rbac/rbac.yaml" mapping "$projectDir/src/main/resources/api-definition/rbac/api-mappings.yaml" targetDir "$projectDir/build/generated/sources/openapi" @@ -124,7 +125,7 @@ } springTest { processorName 'spring' processor 'io.openapiprocessor:openapi-processor-spring:2022.4' processor 'io.openapiprocessor:openapi-processor-spring:2022.5' apiPath "$projectDir/src/main/resources/api-definition/test/test.yaml" mapping "$projectDir/src/main/resources/api-definition/test/api-mappings.yaml" targetDir "$projectDir/build/generated/sources/openapi" @@ -133,7 +134,7 @@ } springHs { processorName 'spring' processor 'io.openapiprocessor:openapi-processor-spring:2022.4' processor 'io.openapiprocessor:openapi-processor-spring:2022.5' apiPath "$projectDir/src/main/resources/api-definition/hs-office/hs-office.yaml" mapping "$projectDir/src/main/resources/api-definition/hs-office/api-mappings.yaml" targetDir "$projectDir/build/generated/sources/openapi" src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java
@@ -5,10 +5,12 @@ import org.iban4j.Iban4jException; import org.springframework.core.NestedExceptionUtils; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.orm.jpa.JpaObjectRetrievalFailureException; import org.springframework.orm.jpa.JpaSystemException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; @@ -65,8 +67,25 @@ @ExceptionHandler(Throwable.class) protected ResponseEntity<CustomErrorResponse> handleOtherExceptions( final Throwable exc, final WebRequest request) { final var message = firstLine(NestedExceptionUtils.getMostSpecificCause(exc).getMessage()); final var message = firstMessageLine(NestedExceptionUtils.getMostSpecificCause(exc)); return errorResponse(request, httpStatus(message).orElse(HttpStatus.INTERNAL_SERVER_ERROR), message); } @Override @SuppressWarnings("unchecked,rawtypes") protected ResponseEntity handleMethodArgumentNotValid( MethodArgumentNotValidException exc, HttpHeaders headers, HttpStatus status, WebRequest request) { final var errorList = exc .getBindingResult() .getFieldErrors() .stream() .map(fieldError -> fieldError.getField() + " " + fieldError.getDefaultMessage() + " but is \"" + fieldError.getRejectedValue() + "\"") .toList(); return errorResponse(request, HttpStatus.BAD_REQUEST, errorList.toString()); } private String userReadableEntityClassName(final String exceptionMessage) { @@ -75,11 +94,11 @@ final var matcher = pattern.matcher(exceptionMessage); if (matcher.find()) { final var entityName = matcher.group(1); final var entityClass = resolveClassOrNull(entityName); if (entityClass != null) { return (entityClass.isAnnotationPresent(DisplayName.class) ? exceptionMessage.replace(entityName, entityClass.getAnnotation(DisplayName.class).value()) : exceptionMessage.replace(entityName, entityClass.getSimpleName())) final var entityClass = resolveClass(entityName); if (entityClass.isPresent()) { return (entityClass.get().isAnnotationPresent(DisplayName.class) ? exceptionMessage.replace(entityName, entityClass.get().getAnnotation(DisplayName.class).value()) : exceptionMessage.replace(entityName, entityClass.get().getSimpleName())) .replace(" with id ", " with uuid "); } @@ -87,11 +106,11 @@ return exceptionMessage; } private static Class<?> resolveClassOrNull(final String entityName) { private static Optional<Class<?>> resolveClass(final String entityName) { try { return ClassLoader.getSystemClassLoader().loadClass(entityName); return Optional.of(ClassLoader.getSystemClassLoader().loadClass(entityName)); } catch (ClassNotFoundException e) { return null; return Optional.empty(); } } @@ -115,6 +134,13 @@ new CustomErrorResponse(request.getContextPath(), httpStatus, message), httpStatus); } private String firstMessageLine(final Throwable exception) { if (exception.getMessage() != null) { return firstLine(exception.getMessage()); } return "ERROR: [500] " + exception.getClass().getName(); } private String firstLine(final String message) { return message.split("\\r|\\n|\\r\\n", 0)[0]; } src/main/java/net/hostsharing/hsadminng/errors/package-info.java
New file @@ -0,0 +1,6 @@ @NonNullApi @NonNullFields package net.hostsharing.hsadminng.errors; import org.springframework.lang.NonNullApi; import org.springframework.lang.NonNullFields; src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java
@@ -19,7 +19,6 @@ import javax.persistence.EntityManager; import java.util.List; import java.util.NoSuchElementException; import java.util.UUID; import java.util.function.BiConsumer; src/main/resources/api-definition/hs-office/hs-office-debitor-schemas.yaml
@@ -22,7 +22,7 @@ type: string vatCountryCode: type: string pattern: '^[A_Z][A-Z]$' pattern: '^[A-Z][A-Z]$' vatBusiness: type: boolean refundBankAccount: @@ -40,7 +40,7 @@ nullable: true vatCountryCode: type: string pattern: '^[A_Z][A-Z]$' pattern: '^[A-Z][A-Z]$' nullable: true vatBusiness: type: boolean @@ -56,9 +56,11 @@ partnerUuid: type: string format: uuid nullable: false billingContactUuid: type: string format: uuid nullable: false debitorNumber: type: integer format: int32 @@ -68,7 +70,7 @@ type: string vatCountryCode: type: string pattern: '^[A_Z][A-Z]$' pattern: '^[A-Z][A-Z]$' vatBusiness: type: boolean refundBankAccountUuid: src/main/resources/api-definition/hs-office/hs-office-partner-schemas.yaml
@@ -15,16 +15,21 @@ $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' registrationOffice: type: string nullable: true registrationNumber: type: string nullable: true birthName: type: string nullable: true birthday: type: string format: date nullable: true dateOfDeath: type: string format: date nullable: true HsOfficePartnerPatch: type: object @@ -84,8 +89,3 @@ required: - personUuid - contactUuid - registrationOffice - registrationNumber - birthName - birthday - dateOfDeath src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java
@@ -5,16 +5,24 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.MethodParameter; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.orm.jpa.JpaObjectRetrievalFailureException; import org.springframework.orm.jpa.JpaSystemException; import org.springframework.validation.BindingResult; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.context.request.WebRequest; import javax.persistence.EntityNotFoundException; import java.util.List; import java.util.NoSuchElementException; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class RestResponseEntityExceptionHandlerUnitTest { @@ -168,6 +176,32 @@ } @Test void handleMethodArgumentNotValidException() { // given final var givenBindingResult = mock(BindingResult.class); when(givenBindingResult.getFieldErrors()).thenReturn(List.of( new FieldError("someObject", "someField", "someRejectedValue", false, null, null, "expected to be something") )); final var givenException = new MethodArgumentNotValidException( mock(MethodParameter.class), givenBindingResult ); final var givenWebRequest = mock(WebRequest.class); // when final var errorResponse = exceptionHandler.handleMethodArgumentNotValid(givenException, HttpHeaders.EMPTY, HttpStatus.BAD_REQUEST, givenWebRequest); // then assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400); assertThat(errorResponse.getBody()) .isInstanceOf(CustomErrorResponse.class) .extracting("message") .isEqualTo("[someField expected to be something but is \"someRejectedValue\"]"); } @Test void handleOtherExceptionsWithoutErrorCode() { // given final var givenThrowable = new Error("First Line\nSecond Line\nThird Line"); @@ -195,6 +229,20 @@ assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [418] First Line"); } @Test void handleOtherExceptionsWithoutMessage() { // given final var givenThrowable = new Error(); final var givenWebRequest = mock(WebRequest.class); // when final var errorResponse = exceptionHandler.handleOtherExceptions(givenThrowable, givenWebRequest); // then assertThat(errorResponse.getStatusCodeValue()).isEqualTo(500); assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [500] java.lang.Error"); } public static class NoDisplayNameEntity { } src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java
@@ -26,8 +26,7 @@ import static net.hostsharing.test.JsonMatcher.lenientlyEquals; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assumptions.assumeThat; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.startsWith; import static org.hamcrest.Matchers.*; @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @@ -190,36 +189,36 @@ } @Test void globalAdmin_withoutAssumedRole_canAddDebitorWithoutBankAccount() { void globalAdmin_canAddDebitorWithoutJustRequiredData() { context.define("superuser-alex@hostsharing.net"); final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Third").get(0); final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); final var location = RestAssured // @formatter:off .given() .given() .header("current-user", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON) .body(""" { "partnerUuid": "%s", "billingContactUuid": "%s", "debitorNumber": "%s", "vatId": "VAT123456", "vatCountryCode": "DE", "vatBusiness": true "debitorNumber": "%s" } """.formatted( givenPartner.getUuid(), givenContact.getUuid(), nextDebitorNumber++)) .port(port) .when() .when() .post("http://localhost/api/hs/office/debitors") .then().log().all().assertThat() .then().log().all().assertThat() .statusCode(201) .contentType(ContentType.JSON) .body("uuid", isUuidValid()) .body("vatId", is("VAT123456")) .body("billingContact.label", is(givenContact.getLabel())) .body("partner.person.tradeName", is(givenPartner.getPerson().getTradeName())) .body("vatId", equalTo(null)) .body("vatCountryCode", equalTo(null)) .body("vatBusiness", equalTo(false)) .body("refundBankAccount", equalTo(null)) .header("Location", startsWith("http://localhost")) .extract().header("Location"); // @formatter:on