Czy zdarzyło Ci się otrzymać odpowiedź HTTP z kodem 200 OK i wiadomością: Ooops, something went wrong? Jeśli tak, wiesz jaki to ból. Jeśli programujesz w Springu i nie chciałbyś sprawiać podobnej przykrości innym osobom, ten wpis jest dla Ciebie! Poznaj sposoby definiowania kodów odpowiedzi w swoich kontrolerach.

1. Brak ustawienia kodu - TEGO NIE RÓB! #

Zacznijmy od przykładowej implementacji kontrolera REST-owego. W poniższym przykładzie aplikacja nie definuje jakie kody HTTP powinny być zwrócone z poszczególnych metod. W związku z tym każda z nich zwróci kod 200 - jeśli nie dojdzie do pojawienia się jakiegoś wyjątku w trakcie przetwarzania żądania.

Oczywiście można tak robić, ale po co?

Każda z metod powinna zwracać kod odpowiadający statusowi żądania. W związku z tym poniższe rozwiązanie nie jest tym, które powinieneś powtarzać w swoim kodzie.

@RequestMapping("/api/tasks")
@RestController
class TasksController {
    private final TasksService tasksService;

    @GetMapping("/{id}")
    public Task getTaskById(@RequestParam("id") Long id) {
        return tasksService.findById(id);
    }

    @PostMapping("/")
    public void createTask(@RequestBody Task task) {
        tasksService.createTask(task);
    }

    @GetMapping
    public List<Task> getTasks() {
        return tasksService.findAll();
    }

    @DeleteMapping("/{id}")
    public void deleteTask(@RequestParam("id") Long id) {
        tasksService.deleteById(id);
    }
}

2. Klasa ResponseEntity #

Rozwiązaniem powyższej sytuacji jest wprowadzenie klasy ResponseEntity. Z jej pomocą możemy łatwo zdefiniować jaki status HTTP powinien być zwrócony.

Oprócz standardowej metody status() pozwalającej na zdefiniowanie konkretnego kodu, możemy także skorzystać z pomocniczych metod jak ok(), badRequest() czy notFound().

@RequestMapping("/api/tasks")
@RestController
class TasksController {
    private final TasksService tasksService;

    @GetMapping("/{id}")
    public ResponseEntity getTaskById(@RequestParam("id") Long id) {
        return ResponseEntity.ok(tasksService.findById(id));
    }

    @PostMapping("/")
    public ResponseEntity createTask(UriComponentBuilder builder, @RequestBody Task task) {
        Long taskId = tasksService.createTask(task).getId();
        UriComponents uriComponents = builder.path("/customers/{id}").buildAndExpand(taskId);
        return ResponseEntity.created();
    }

    @GetMapping
    public ResponseEntity getTasks() {
        return ResponseEntity.ok(tasksService.findAll());
    }

    @DeleteMapping("/{id}")
    public ResponseEntity deleteTask(@RequestParam("id") Long id) {
        try {
            tasksService.deleteById(id);
            return ResponseEntity.noContent();
        } catch (TaskNotFoundException e) {
            return ResponseEntity.notFound();
        }
    }
}

3. Adnotacja @ResponseStatus #

W Springu istnieje jeszcze adnotacja @ResponseStatus, którą możemy zdefiniować nad metodą. Jest to pomost pomiędzy sposobem pierwszym a drugim. Z jednej strony zyskujemy możliwość definiowania kodów odpowiedzi, ale z drugiej te kody są statyczne i dana metoda nie może zwrócić innego kodu. W takim wypadku na poziomie kontrolera nie możemy odróżnić czy metoda powinna zwracać kod 201 czy 204.

@RequestMapping("/api/tasks")
@RestController
class TasksController {
    private final TasksService tasksService;

    @GetMapping("/{id}")
    @ResponseStatus(200)
    public Task getTaskById(@RequestParam("id") Long id) {
        return tasksService.findById(id);
    }

    @PostMapping("/")
    @ResponseStatus(201)
    public void createTask(Task task) {
        tasksService.createTask(task);
    }

    @GetMapping
    @ResponseStatus(200)
    public List<Task> getTasks() {
        return tasksService.findAll();
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(204)
    public void deleteTask(@RequestParam("id") Long id) {
        tasksService.deleteById(id);
    }
}

Psst… Mam nadzieję, że masz już mój ebook o 10 sztuczkach senior developerów w Springu? ☘️

4. @ExceptionHandler #

W ciele kontrolera możemy umieścić też specjalne metody odpowiedzialne za obsługę wyjątków. Wystarczy zdefiniować specjalne metody z adnotacją @ExceptionHandler, w której definiujemy które wyjątki powinny łapać. W ten sposób na poziomie pojedynczego kontrolera możemy zdefiniować specjalne handlery.

@RequestMapping("/api/tasks")
@RestController
class TasksController {
    // ..

    @ExceptionHandler(TaskNotFoundException.class)
    public final ResponseEntity<Error> handleException(TaskNotFoundException ex) {
        HttpHeaders headers = new HttpHeaders();
        HttpStatus status = HttpStatus.NOT_FOUND;
        TaskNotFoundException tne = (TaskNotFoundException) ex;
        return ResponseEntity.status(status);
    }

    @ExceptionHandler(UnauthorizedException.class)
    public final ResponseEntity<Error> handleException(UnauthorizedException ex) {
        HttpHeaders headers = new HttpHeaders();
        HttpStatus status = HttpStatus.UNAUTHORIZED;
        UnauthorizedException ue = (UnauthorizedException) ex;
        return ResponseEntity.status(status);
    }
}

5. @ControllerAdvice #

Definiowanie exception handlerów wewnątrz wszystkich kontrolerów może być uciążliwe. Większość wyjątków - jak UnauthorizedException, czy UserNotFoundException chcemy obsłużyć w ten sam sposób.

Dlatego rozwiązaniem jest globalny *Exception Handler&, który zajmie się obsługą wyjątków z wszystkich kontrolerów.

Wystarczy zdefiniować następującą klasę:

@ControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler({ UnauthorizedException.class, TaskNotFoundException.class })
    public final ResponseEntity<ApiError> handleException(Exception ex, WebRequest request) {
        HttpHeaders headers = new HttpHeaders();
        if (ex instanceof UnauthorizedException) {
            HttpStatus status = HttpStatus.NOT_FOUND;
            UnauthorizedException ue = (UnauthorizedException) ex;

            return handleUserNotFoundException(ue, headers, status, request);
        } else if (ex instanceof TaskNotFoundException) {
            HttpStatus status = HttpStatus.BAD_REQUEST;
            TaskNotFoundException tnfe = (TaskNotFoundException) ex;

            return handleContentNotAllowedException(tnfe, headers, status, request);
        } else {
            HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
            return handleExceptionInternal(ex, null, headers, status, request);
        }
    }

}

Dzięki niej popularne błędy możemy rozwiązywać w jednym miejscu. Oczywiście można łączyć te metody - @ControllerAdvice z @ExceptionHandler-em wewnątrz klasy. Wówczas definicja z klasy ma wyższy priorytet.

6. ResponseStatusException #

Istnieje jeszcze jeden sposób definiowania zwracanego kodu HTTP w przypadku pojawienia się wyjątku w naszym kodzie. W Springu 5 pojawił się nowy typ wyjątku - ResponseStatusException, który możemy rzucić z ciała naszej metody.

@DeleteMapping("/{id}")
@ResponseStatus(204)
public void deleteTask(@RequestParam("id") Long id) {
    try {
        tasksService.deleteById(id);
    } catch (TaskNotFoundException e) {
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Provide correct task ID", ex);
    }
}

ResponseStatusException zostanie przechwycony i obsłużony już przez samego Springa zwracając ładnie sformatowaną i przygotowaną odpowiedź z odpowiednim kodem.

{
    "timestamp": "2020-02-04T00:08:11.432+0000",
    "status": 404,
    "error": "Not Found",
    "message": "Provide correct task ID",
    "path": "/api/tasks/42"
}

Podsumowanie #

Przez lata w Springu pojawiło się wiele narzędzi do definiowania odpowiednich statusów HTTP z REST kontrolerów. Najważniejsza lekcja z tego wpisu to nauczyć się jak ich używać i dbać o to, by były one określone zgodnie z ich przeznaczeniem. Nikt nie lubi otrzymywać odpowiedzi z kodem 200 i treścią “Oops, something went wrong” ;).