improved REST-API error handling for broken request body JSON
This commit is contained in:
parent
e305c3c935
commit
77ef126a7e
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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 -> {
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user