improved REST-API error handling for broken request body JSON

This commit is contained in:
Michael Hoennig 2022-10-16 10:08:42 +02:00
parent e305c3c935
commit 77ef126a7e
5 changed files with 94 additions and 47 deletions

View File

@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.config; package net.hostsharing.hsadminng.config;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.openapitools.jackson.nullable.JsonNullableModule; import org.openapitools.jackson.nullable.JsonNullableModule;
@ -16,6 +17,7 @@ public class JsonObjectMapperConfiguration {
public Jackson2ObjectMapperBuilder customObjectMapper() { public Jackson2ObjectMapperBuilder customObjectMapper() {
return new Jackson2ObjectMapperBuilder() return new Jackson2ObjectMapperBuilder()
.modules(new JsonNullableModule(), new JavaTimeModule()) .modules(new JsonNullableModule(), new JavaTimeModule())
.featuresToEnable(JsonParser.Feature.ALLOW_COMMENTS)
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
} }
} }

View File

@ -0,0 +1,51 @@
package net.hostsharing.hsadminng.errors;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.context.request.WebRequest;
import java.time.LocalDateTime;
@Getter
class CustomErrorResponse {
static ResponseEntity<CustomErrorResponse> errorResponse(
final WebRequest request,
final HttpStatus httpStatus,
final String message) {
return new ResponseEntity<>(
new CustomErrorResponse(request.getContextPath(), httpStatus, message), httpStatus);
}
static String firstMessageLine(final Throwable exception) {
if (exception.getMessage() != null) {
return firstLine(exception.getMessage());
}
return "ERROR: [500] " + exception.getClass().getName();
}
static String firstLine(final String message) {
return message.split("\\r|\\n|\\r\\n", 0)[0];
}
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd hh:mm:ss")
private final LocalDateTime timestamp;
private final String path;
private final int status;
private final String error;
private final String message;
CustomErrorResponse(final String path, final HttpStatus status, final String message) {
this.timestamp = LocalDateTime.now();
this.path = path;
this.status = status.value();
this.error = status.getReasonPhrase();
this.message = message;
}
}

View File

@ -1,13 +1,12 @@
package net.hostsharing.hsadminng.errors; package net.hostsharing.hsadminng.errors;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import org.iban4j.Iban4jException; import org.iban4j.Iban4jException;
import org.springframework.core.NestedExceptionUtils; import org.springframework.core.NestedExceptionUtils;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.orm.jpa.JpaObjectRetrievalFailureException; import org.springframework.orm.jpa.JpaObjectRetrievalFailureException;
import org.springframework.orm.jpa.JpaSystemException; import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
@ -17,11 +16,12 @@ import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import javax.persistence.EntityNotFoundException; import javax.persistence.EntityNotFoundException;
import java.time.LocalDateTime;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
import java.util.Optional; import java.util.Optional;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import static net.hostsharing.hsadminng.errors.CustomErrorResponse.*;
@ControllerAdvice @ControllerAdvice
public class RestResponseEntityExceptionHandler public class RestResponseEntityExceptionHandler
extends ResponseEntityExceptionHandler { extends ResponseEntityExceptionHandler {
@ -73,6 +73,16 @@ public class RestResponseEntityExceptionHandler
@Override @Override
@SuppressWarnings("unchecked,rawtypes") @SuppressWarnings("unchecked,rawtypes")
protected ResponseEntity handleExceptionInternal(
Exception exc, @Nullable Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {
final var response = super.handleExceptionInternal(exc, body, headers, status, request);
return errorResponse(request, response.getStatusCode(),
Optional.ofNullable(response.getBody()).map(Object::toString).orElse(firstMessageLine(exc)));
}
//@ExceptionHandler({ MethodArgumentNotValidException.class })
@SuppressWarnings("unchecked,rawtypes")
protected ResponseEntity handleMethodArgumentNotValid( protected ResponseEntity handleMethodArgumentNotValid(
MethodArgumentNotValidException exc, MethodArgumentNotValidException exc,
HttpHeaders headers, HttpHeaders headers,
@ -126,45 +136,4 @@ public class RestResponseEntityExceptionHandler
return Optional.empty(); return Optional.empty();
} }
private static ResponseEntity<CustomErrorResponse> errorResponse(
final WebRequest request,
final HttpStatus httpStatus,
final String message) {
return new ResponseEntity<>(
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];
}
}
@Getter
class CustomErrorResponse {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd hh:mm:ss")
private final LocalDateTime timestamp;
private final String path;
private final int status;
private final String error;
private final String message;
public CustomErrorResponse(final String path, final HttpStatus status, final String message) {
this.timestamp = LocalDateTime.now();
this.path = path;
this.status = status.value();
this.error = status.getReasonPhrase();
this.message = message;
}
} }

View File

@ -136,7 +136,7 @@ class HsOfficeBankAccountControllerAcceptanceTest {
.port(port) .port(port)
.when() .when()
.post("http://localhost/api/hs/office/bankaccounts") .post("http://localhost/api/hs/office/bankaccounts")
.then().assertThat() .then().log().all().assertThat()
.statusCode(201) .statusCode(201)
.contentType(ContentType.JSON) .contentType(ContentType.JSON)
.body("uuid", isUuidValid()) .body("uuid", isUuidValid())
@ -346,7 +346,10 @@ class HsOfficeBankAccountControllerAcceptanceTest {
jpaAttempt.transacted(() -> { jpaAttempt.transacted(() -> {
context.define("superuser-alex@hostsharing.net", null); context.define("superuser-alex@hostsharing.net", null);
tempBankAccountUuids.addAll( tempBankAccountUuids.addAll(
bankAccountRepo.findByOptionalHolderLike("some temp acc").stream().map(HsOfficeBankAccountEntity::getUuid).toList() bankAccountRepo.findByOptionalHolderLike("some temp acc")
.stream()
.map(HsOfficeBankAccountEntity::getUuid)
.toList()
); );
}); });
tempBankAccountUuids.forEach(uuid -> { tempBankAccountUuids.forEach(uuid -> {

View File

@ -247,6 +247,26 @@ class TestCustomerControllerAcceptanceTest {
context.define("superuser-fran@hostsharing.net"); context.define("superuser-fran@hostsharing.net");
assertThat(testCustomerRepository.findCustomerByOptionalPrefixLike("uuu")).hasSize(0); assertThat(testCustomerRepository.findCustomerByOptionalPrefixLike("uuu")).hasSize(0);
} }
@Test
void invalidRequestBodyJson_raisesClientError() {
RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("{]") // deliberately invalid JSON
.port(port)
.when()
.post("http://localhost/api/test/customers")
.then().assertThat()
.statusCode(400)
.contentType(ContentType.JSON)
.body("message", containsString("JSON parse error: Unexpected close marker ']': expected '}'"))
.body("message", containsString("line: 1, column: 1"));
// @formatter:on
}
} }
private UUID toCleanup(final UUID tempPartnerUuid) { private UUID toCleanup(final UUID tempPartnerUuid) {
@ -262,7 +282,9 @@ class TestCustomerControllerAcceptanceTest {
System.out.println("DELETING temporary partner: " + uuid); System.out.println("DELETING temporary partner: " + uuid);
final var entity = testCustomerRepository.findByUuid(uuid); final var entity = testCustomerRepository.findByUuid(uuid);
final var count = testCustomerRepository.deleteByUuid(uuid); final var count = testCustomerRepository.deleteByUuid(uuid);
System.out.println("DELETED temporary partner: " + uuid + (count > 0 ? " successful" : " failed") + " (" + entity.map(TestCustomerEntity::getPrefix).orElse("???") + ")"); System.out.println(
"DELETED temporary partner: " + uuid + (count > 0 ? " successful" : " failed") + " (" + entity.map(
TestCustomerEntity::getPrefix).orElse("???") + ")");
}).assertSuccessful(); }).assertSuccessful();
}); });
} }