Michael Hoennig
2022-10-15 67e850f9b2d692d24569bb2f92ed0b01c5540fd4
add API validation
1 files added
7 files modified
147 ■■■■ changed files
build.gradle 9 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java 44 ●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/errors/package-info.java 6 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java 1 ●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/hs-office-debitor-schemas.yaml 8 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/hs-office-partner-schemas.yaml 10 ●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java 48 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java 21 ●●●● patch | view | raw | blame | history
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