Перейти к основному содержимому

Пользовательская обработка сообщений об ошибках для REST API

· 7 мин. чтения

1. Обзор

В этом руководстве мы обсудим, как реализовать глобальный обработчик ошибок для Spring REST API.

Мы будем использовать семантику каждого исключения для создания осмысленных сообщений об ошибках для клиента с четкой целью предоставить этому клиенту всю информацию, чтобы легко диагностировать проблему.

2. Пользовательское сообщение об ошибке

Начнем с реализации простой структуры для отправки ошибок по сети — ApiError :

public class ApiError {

private HttpStatus status;
private String message;
private List<String> errors;

public ApiError(HttpStatus status, String message, List<String> errors) {
super();
this.status = status;
this.message = message;
this.errors = errors;
}

public ApiError(HttpStatus status, String message, String error) {
super();
this.status = status;
this.message = message;
errors = Arrays.asList(error);
}
}

Информация здесь должна быть простой:

  • status — код состояния HTTP
  • message – сообщение об ошибке, связанное с исключением
  • error – Список созданных сообщений об ошибках

И, конечно же, для фактической логики обработки исключений в Spring мы будем использовать аннотацию @ControllerAdvice :

@ControllerAdvice
public class CustomRestExceptionHandler extends ResponseEntityExceptionHandler {
...
}

3. Обработка исключений неверных запросов

3.1. Обработка исключений

Теперь давайте посмотрим, как мы можем обрабатывать наиболее распространенные ошибки клиента — в основном сценарии, когда клиент отправляет недопустимый запрос к API:

  • BindException — это исключение возникает при возникновении неустранимых ошибок привязки.

MethodArgumentNotValidException — это исключение выдается, когда аргумент с аннотацией @Valid не прошел проверку:

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatus status,
WebRequest request) {
List<String> errors = new ArrayList<String>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
errors.add(error.getField() + ": " + error.getDefaultMessage());
}
for (ObjectError error : ex.getBindingResult().getGlobalErrors()) {
errors.add(error.getObjectName() + ": " + error.getDefaultMessage());
}

ApiError apiError =
new ApiError(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage(), errors);
return handleExceptionInternal(
ex, apiError, headers, apiError.getStatus(), request);
}

Обратите внимание, что мы переопределяем базовый метод из ResponseEntityExceptionHandler и предоставляем собственную реализацию.

Так будет не всегда. Иногда нам потребуется обработать пользовательское исключение, которое не имеет реализации по умолчанию в базовом классе, как мы увидим позже.

Следующий:

MissingServletRequestPartException — это исключение возникает, когда часть составного запроса не найдена.

MissingServletRequestParameterException — это исключение возникает, когда в запросе отсутствует параметр:

@Override
protected ResponseEntity<Object> handleMissingServletRequestParameter(
MissingServletRequestParameterException ex, HttpHeaders headers,
HttpStatus status, WebRequest request) {
String error = ex.getParameterName() + " parameter is missing";

ApiError apiError =
new ApiError(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage(), error);
return new ResponseEntity<Object>(
apiError, new HttpHeaders(), apiError.getStatus());
}

ConstraintViolationException — это исключение сообщает о результате нарушения ограничений:

@ExceptionHandler({ ConstraintViolationException.class })
public ResponseEntity<Object> handleConstraintViolation(
ConstraintViolationException ex, WebRequest request) {
List<String> errors = new ArrayList<String>();
for (ConstraintViolation<?> violation : ex.getConstraintViolations()) {
errors.add(violation.getRootBeanClass().getName() + " " +
violation.getPropertyPath() + ": " + violation.getMessage());
}

ApiError apiError =
new ApiError(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage(), errors);
return new ResponseEntity<Object>(
apiError, new HttpHeaders(), apiError.getStatus());
}

TypeMismatchException — это исключение возникает при попытке установить свойство bean-компонента с неправильным типом.

MethodArgumentTypeMismatchException — это исключение возникает, когда аргумент метода не соответствует ожидаемому типу:

@ExceptionHandler({ MethodArgumentTypeMismatchException.class })
public ResponseEntity<Object> handleMethodArgumentTypeMismatch(
MethodArgumentTypeMismatchException ex, WebRequest request) {
String error =
ex.getName() + " should be of type " + ex.getRequiredType().getName();

ApiError apiError =
new ApiError(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage(), error);
return new ResponseEntity<Object>(
apiError, new HttpHeaders(), apiError.getStatus());
}

3.2. Использование API от клиента

Теперь давайте посмотрим на тест, который вызывает исключение MethodArgumentTypeMismatchException .

Мы отправим запрос с id как String вместо long :

@Test
public void whenMethodArgumentMismatch_thenBadRequest() {
Response response = givenAuth().get(URL_PREFIX + "/api/foos/ccc");
ApiError error = response.as(ApiError.class);

assertEquals(HttpStatus.BAD_REQUEST, error.getStatus());
assertEquals(1, error.getErrors().size());
assertTrue(error.getErrors().get(0).contains("should be of type"));
}

И, наконец, учитывая этот же запрос:

Request method: GET
Request path: http://localhost:8080/spring-security-rest/api/foos/ccc

вот как будет выглядеть такой ответ об ошибке JSON :

{
"status": "BAD_REQUEST",
"message":
"Failed to convert value of type [java.lang.String]
to required type [java.lang.Long]; nested exception
is java.lang.NumberFormatException: For input string: \"ccc\"",
"errors": [
"id should be of type java.lang.Long"
]
}

4. Обработка исключения NoHandlerFoundException

Затем мы можем настроить наш сервлет так, чтобы он выдавал это исключение вместо отправки ответа 404:

<servlet>
<servlet-name>api</servlet-name>
<servlet-class>
org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>throwExceptionIfNoHandlerFound</param-name>
<param-value>true</param-value>
</init-param>
</servlet>

Затем, как только это произойдет, мы можем просто обработать его, как и любое другое исключение:

@Override
protected ResponseEntity<Object> handleNoHandlerFoundException(
NoHandlerFoundException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
String error = "No handler found for " + ex.getHttpMethod() + " " + ex.getRequestURL();

ApiError apiError = new ApiError(HttpStatus.NOT_FOUND, ex.getLocalizedMessage(), error);
return new ResponseEntity<Object>(apiError, new HttpHeaders(), apiError.getStatus());
}

Вот простой тест:

@Test
public void whenNoHandlerForHttpRequest_thenNotFound() {
Response response = givenAuth().delete(URL_PREFIX + "/api/xx");
ApiError error = response.as(ApiError.class);

assertEquals(HttpStatus.NOT_FOUND, error.getStatus());
assertEquals(1, error.getErrors().size());
assertTrue(error.getErrors().get(0).contains("No handler found"));
}

Давайте посмотрим на полный запрос:

Request method: DELETE
Request path: http://localhost:8080/spring-security-rest/api/xx

и ответ JSON об ошибке :

{
"status":"NOT_FOUND",
"message":"No handler found for DELETE /spring-security-rest/api/xx",
"errors":[
"No handler found for DELETE /spring-security-rest/api/xx"
]
}

Далее мы рассмотрим еще одно интересное исключение.

5. Обработка исключения HttpRequestMethodNotSupportedException

Исключение HttpRequestMethodNotSupportedException возникает, когда мы отправляем запрос с неподдерживаемым методом HTTP:

@Override
protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
HttpRequestMethodNotSupportedException ex,
HttpHeaders headers,
HttpStatus status,
WebRequest request) {
StringBuilder builder = new StringBuilder();
builder.append(ex.getMethod());
builder.append(
" method is not supported for this request. Supported methods are ");
ex.getSupportedHttpMethods().forEach(t -> builder.append(t + " "));

ApiError apiError = new ApiError(HttpStatus.METHOD_NOT_ALLOWED,
ex.getLocalizedMessage(), builder.toString());
return new ResponseEntity<Object>(
apiError, new HttpHeaders(), apiError.getStatus());
}

Вот простой тест, воспроизводящий это исключение:

@Test
public void whenHttpRequestMethodNotSupported_thenMethodNotAllowed() {
Response response = givenAuth().delete(URL_PREFIX + "/api/foos/1");
ApiError error = response.as(ApiError.class);

assertEquals(HttpStatus.METHOD_NOT_ALLOWED, error.getStatus());
assertEquals(1, error.getErrors().size());
assertTrue(error.getErrors().get(0).contains("Supported methods are"));
}

А вот и полный запрос:

Request method: DELETE
Request path: http://localhost:8080/spring-security-rest/api/foos/1

и ответ JSON об ошибке :

{
"status":"METHOD_NOT_ALLOWED",
"message":"Request method 'DELETE' not supported",
"errors":[
"DELETE method is not supported for this request. Supported methods are GET "
]
}

6. Обработка исключения HttpMediaTypeNotSupportedException

Теперь давайте обработаем HttpMediaTypeNotSupportedException , которое возникает, когда клиент отправляет запрос с неподдерживаемым типом носителя:

@Override
protected ResponseEntity<Object> handleHttpMediaTypeNotSupported(
HttpMediaTypeNotSupportedException ex,
HttpHeaders headers,
HttpStatus status,
WebRequest request) {
StringBuilder builder = new StringBuilder();
builder.append(ex.getContentType());
builder.append(" media type is not supported. Supported media types are ");
ex.getSupportedMediaTypes().forEach(t -> builder.append(t + ", "));

ApiError apiError = new ApiError(HttpStatus.UNSUPPORTED_MEDIA_TYPE,
ex.getLocalizedMessage(), builder.substring(0, builder.length() - 2));
return new ResponseEntity<Object>(
apiError, new HttpHeaders(), apiError.getStatus());
}

Вот простой тест для решения этой проблемы:

@Test
public void whenSendInvalidHttpMediaType_thenUnsupportedMediaType() {
Response response = givenAuth().body("").post(URL_PREFIX + "/api/foos");
ApiError error = response.as(ApiError.class);

assertEquals(HttpStatus.UNSUPPORTED_MEDIA_TYPE, error.getStatus());
assertEquals(1, error.getErrors().size());
assertTrue(error.getErrors().get(0).contains("media type is not supported"));
}

Наконец, вот пример запроса:

Request method: POST
Request path: http://localhost:8080/spring-security-
Headers: Content-Type=text/plain; charset=ISO-8859-1

и ответ JSON об ошибке:

{
"status":"UNSUPPORTED_MEDIA_TYPE",
"message":"Content type 'text/plain;charset=ISO-8859-1' not supported",
"errors":["text/plain;charset=ISO-8859-1 media type is not supported.
Supported media types are text/xml
application/x-www-form-urlencoded
application/*+xml
application/json;charset=UTF-8
application/*+json;charset=UTF-8 */"
]
}

7. Обработчик по умолчанию

Наконец, мы собираемся реализовать резервный обработчик — универсальный тип логики, который работает со всеми другими исключениями, у которых нет конкретных обработчиков:

@ExceptionHandler({ Exception.class })
public ResponseEntity<Object> handleAll(Exception ex, WebRequest request) {
ApiError apiError = new ApiError(
HttpStatus.INTERNAL_SERVER_ERROR, ex.getLocalizedMessage(), "error occurred");
return new ResponseEntity<Object>(
apiError, new HttpHeaders(), apiError.getStatus());
}

8. Заключение

Создание правильного, зрелого обработчика ошибок для Spring REST API — сложный и, безусловно, итеративный процесс. Надеемся, что это руководство станет хорошей отправной точкой, а также хорошим ориентиром, который поможет клиентам API быстро и легко диагностировать ошибки и преодолевать их.

Полную реализацию этого туториала можно найти в проекте GitHub . Это проект на основе Eclipse, поэтому его легко импортировать и запускать как есть.