Spring Boot REST API – обработка исключений

При создании REST API всегда возникают требования к формату ответа, выдаваемого при ошибке (например, при отсутствии данных или неправильном запросе). И конечно, этот формат не совпадает с тем, что выдается Spring Boot по умолчанию.

Давайте рассмотрим:

• Как исправить JSON, который в Spring Boot выдается по умолчанию при ошибке, на свой другой
• Как обрабатывать исключения, выбрасываемые со всех контроллеров в едином классе, не распыляясь по всему приложению

Предварительные условия

Обрабатывать исключения будем в простом Spring Boot приложении, о его создании рассказано тут. Оно предоставляет REST API для сущности Person:

@Entity
public class Person {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)

    private Long id;
    @NotNull
    private String name;

    public Person() {
    }

    public Person(String name) {
        this.name = name;
    }

    //  getters and setters
}

Исходники первоначальной заготовки можно скачать на GitHub.

Какой JSON выдается по умолчанию при ошибке

Попробуем ошибиться – сделать запрос с ошибочным JSON. Я делаю запрос с помощью программы POSTMAN, но можно тестировать запросы любым другим способом:

POST http://localhost:8080/persons
{
 "name": "Jane"l
}

Spring выдает нам такой ответ:

{
    "timestamp": "2018-07-09T09:58:25.455+0000",
    "status": 400,
    "error": "Bad Request",
    "message": "JSON parse error: Unexpected character ('l' (code 108)): was expecting comma to separate Object entries; nested exception is com.fasterxml.jackson.core.JsonParseException: Unexpected character ('l' (code 108)): was expecting comma to separate Object entries\n at [Source: (PushbackInputStream); line: 3, column: 17]",
    "path": "/persons"
}

Какой JSON-ответ нам нужен

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

{
    "status": "BAD_REQUEST",
    "message": "Malformed JSON request",
    "debugMessage": "JSON parse error: Unexpected character ('l' (code 108)): was expecting comma to separate Object entries; nested exception is com.fasterxml.jackson.core.JsonParseException: Unexpected character ('l' (code 108)): was expecting comma to separate Object entries\n at [Source: (PushbackInputStream); line: 2, column: 18]"
}

Суть в том, что мы его немного поменяли, и ниже будет показано, как это сделать. В реальной жизни тут можно вставить любые поля под свои нужды.

Класс обработки исключений

Для того, чтобы обрабатывать все исключения, выбрасываемые из контроллеров, создадим класс RestExceptionHandler, который расширяет имеющийся класс ResponseEntityExceptionHandler:

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

@ControllerAdvice

Обратите внимание, что наш класс обработки исключений необходимо аннотировать с помощью @ControllerAdvice

Класс ResponseEntityExceptionHandler

В этом классе, который мы расширили, уже есть ряд методов, обрабатывающих исключения, в том числе сэмулированное выше исключение  HttpMessageNotReadableException. Чтобы вернуть свой JSON, надо переопределить метод handleHttpMessageNotReadable():

@Override
protected ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException ex,
    HttpHeaders headers, HttpStatus status, WebRequest request) {
    ApiError apiError = new ApiError(status, "Malformed JSON request", ex);
    return new ResponseEntity(apiError, status);
}

Как видите, ошибка здесь возвращается в ApiError – этот класс как раз и проектируется с учетом требований к формату возвращаемой ошибке. В нем есть все поля, которые нужны именно нам.

 ApiError

У нас он выглядит так:

@JsonInclude(Include.NON_NULL)
public class ApiError {
    
    private HttpStatus status;
    private String message;
    
    private String debugMessage;
    
    private List<FieldValidationError> fieldValidationErrors;

    ...
}

Обратите внимание, что мы создали еще поле fieldValidationErrors, которое будет содержать ошибки валидиции полей JPA-сущностей, в нашем случае полей сущности Person.  Мы не видели это поле в ответе, поскольку ApiError аннотировано @JsonInclude(Include.NON_NULL), а значит нулевые поля не сериализуются и не включаются в ответ. А поскольку в предыдущем запросе ошибок валидации не возникало, это поле было нулевым.

Да, валидация инициируется только в том случае, если в котроллере перед аргументом стоит аннотация @Valid. В нашем контроллере в методе createPerson() она как раз стоит:

@PostMapping
public Person createPerson(@RequestBody @Valid Person person) {
    return personRepository.save(person);
}

Еще один некорректный запрос

Давайте теперь сделаем запрос с корректным JSON но невалидной JPA-сущностью, чтобы ответ содержал ошибки валидации. Например, пусть сущность не содержит поле name:

POST http://localhost:8080/persons
{
  "namet": "Jane"
}

Поскольку поле name сущности Person аннотированно @NotNull, такой запрос должен повлечь ошибку валидации поля. А исключения, касающиеся валидации полей, обрабатываются в методе handleMethodArgumentNotValid(), вот его то мы и переопределим для создания нашего JSON с перечнем ошибок для каждого поля.

handleMethodArgumentNotValid()

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
        HttpHeaders headers, HttpStatus status, WebRequest request) {

    ApiError apiError = new ApiError(status, "method arg not valid", ex);
    apiError.addValidationErrors(ex.getBindingResult().getFieldErrors());

    return new ResponseEntity<Object>(apiError, HttpStatus.BAD_REQUEST);
}

Как видите, здесь мы еще заполнили список fieldValidationErrors нашего класса ApiError, и взяли мы его из ex.getBindingResult().getFieldErrors() исключения Spring MethodArgumentNotValidException. Вот такое дополнительное перекладывание ошибок в нашу пользовательскую ApiError в пользовательском методе обработки исключения MethodArgumentNotValidException.

Выглядит класс FieldValidationError так:

public class FieldValidationError {

	private String object;
	private String field;
	private Object rejectedValue;
	private String message;

        // constructor/getters/setters и 
}

Итак, какой же теперь мы получим ответ на ошибочный запрос:

{
    "status": "BAD_REQUEST",
    "message": "method arg not valid",
    "debugMessage": "Validation failed for argument at index 0 in method: public ru.sysout.springsecurity.model.Person ru.sysout.springsecurity.controller.PersonController.createPerson(ru.sysout.springsecurity.model.Person), with 1 error(s): [Field error in object 'person' on field 'name': rejected value [null]; codes [NotNull.person.name,NotNull.name,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [person.name,name]; arguments []; default message [name]]; default message [должно быть задано]] ",
    "apiValidationErrors": [
        {
            "object": "person",
            "field": "name",
            "rejectedValue": null,
            "message": "должно быть задано"
        }
    ]
}

Пользовательское исключение

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

Но у нас же есть еще и собственные пользовательские исключения. Как быть с ними? Переопределить можно только методы, обрабатывающие исключения Spring. Для обработки пользовательского же исключения надо дописать метод и аннотировать его. Ниже покажу как. Но сначала само исключение – мы будем его выбрасывать в том случае, если сущности не найдено. Например, мы получаем в контроллере список Person и выбрасываем исключение, если он пустой:

   @GetMapping
   public ResponseEntity<List<Person>> listAllPersons() throws EntityNotFoundException {
     //  personRepository.save(new Person("Kate"));
       List<Person> persons = personRepository.findAll();
       if (persons.isEmpty()) {
           throw new EntityNotFoundException();
       }
       return new ResponseEntity<List<Person>>(persons, HttpStatus.OK);
   }

Пользовательское исключение конечно должно передавать дополнительную информацию, но для простоты пусть оно будет таким:

public class EntityNotFoundException extends Exception{
}

Итак, как же его обрабатывать в нашем классе? Для этого добавим в него новый метод, аннотированный @ExceptionHandler(EntityNotFoundException.class)

@ExceptionHandler(EntityNotFoundException.class)
protected ResponseEntity&lt;Object&gt; handleEntityNotFoundEx(EntityNotFoundException ex, WebRequest request) {
    
    ApiError apiError = new ApiError(HttpStatus.NOT_FOUND, "entity not found ex", ex);
    return new ResponseEntity&lt;&gt;(apiError, HttpStatus.NOT_FOUND);
}

 

Он будет вызываться, когда контроллер выбросит исключение EntityNotFoundException и формировать JSON-ответ.

Проверим, как это работает, сделав запрос:

GET http://localhost:8080/persons/

Получим ответ:

{
    "status": "NOT_FOUND",
    "message": "entity not found ex"
}

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

Исключение MethodArgumentTypeMismatchException

Полезно знать еще исключение MethodArgumentTypeMismatchException, оно возникает, если тип аргумента неверный. Например, наш контроллер заточен на получение Person по id:

@GetMapping(value = "/{personId}")
   public Person getPerson(@PathVariable("personId") Long personId) throws EntityNotFoundException {
       return personRepository.getOne(personId);
   }

А мы передаем не целое, а строковое значение id:

GET http://localhost:8080/persons/mn

Тут то и возникает исключение MethodArgumentTypeMismatchException. Давайте его обработаем:

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
    protected ResponseEntity<Object> handleMethodArgumentTypeMismatch(MethodArgumentTypeMismatchException ex,
            WebRequest request) {
        ApiError apiError = new ApiError(BAD_REQUEST);
        apiError.setMessage(String.format("The parameter '%s' of value '%s' could not be converted to type '%s'",
                ex.getName(), ex.getValue(), ex.getRequiredType().getSimpleName()));
        apiError.setDebugMessage(ex.getMessage());
        return new ResponseEntity<>(apiError, HttpStatus.BAD_REQUEST);
    }

Проверим ответ сервера:

{
    "status": "BAD_REQUEST",
    "message": "The parameter 'personId' of value 'mn' could not be converted to type 'Long'",
    "debugMessage": "Failed to convert value of type 'java.lang.String' to required type 'java.lang.Long'; nested exception is java.lang.NumberFormatException: For input string: \"mn\""
}

Исключение handleNoHandlerFoundException

Еще одно полезное исключение handleNoHandlerFoundException – оно возникает, если на данный запрос не найдено обработчика.

Например, сделаем запрос:

GET http://localhost:8080/pers

По данному адресу у нас нет контроллера, так что возникнет handleNoHandlerFoundException.  Добавим обработку исключения:

@Override
    protected ResponseEntity<Object> handleNoHandlerFoundException(NoHandlerFoundException ex, HttpHeaders headers,
            HttpStatus status, WebRequest request) {
        return new ResponseEntity<Object>(new ApiError(status, "no handler found", ex), HttpStatus.NOT_FOUND);
    }

Только учтите, для того, чтобы обработчик вызывался, надо поменять файл application.properties:

spring.mvc.throw-exception-if-no-handler-found=true
spring.resources.add-mappings=false

Проверим ответ сервера:

{
    "status": "NOT_FOUND",
    "message": "no handler found",
    "debugMessage": "No handler found for GET /pers"
}

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

Этот обработчик будет ловить исключения, не пойманные предыдущими обработчиками (но не все):

@ExceptionHandler(Exception.class)
    protected ResponseEntity<Object> handleAllExceptions(Exception ex, WebRequest request) {
        ApiError apiError = new ApiError(HttpStatus.INTERNAL_SERVER_ERROR, "prosto exception", ex);
        return new ResponseEntity<>(apiError, HttpStatus.INTERNAL_SERVER_ERROR);
    }

Заключение

Мы рассмотрели, как переопределить формат  JSON-ответа, выдаваемого при возникновении исключения в Spring Boot, и как закодировать обработку исключений в едином классе. Код приведенного проекта доступен на GitHub.

Spring Boot REST API – обработка исключений: 2 комментария

  1. было бы неплохо увидеть все импорты класса ApiError, а так же, его конструктор:
    ApiError(status, “Malformed JSON request”, ex)

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *