58
64

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Spring Bootで作成したREST APIのエラーレスポンスをカスタムする

Last updated at Posted at 2018-07-27

Spring Boot + MySQLでシンプルなWeb REST APIサーバを実装する - Qiita

Outline

Spring Bootで作成したREST APIのエラーレスポンスを独自に定義する。

Before

{
  "timestamp": "2018-07-20T12:11:51.131+0000",
  "status": 500,
  "error": "Internal Server Error",
  "message": "No message available",
  "path": "/v1/users/hoge"
}

After

{
  "Error": {
    "Message": "Not Found.",
    "Detail": "",
    "Code": ""
  }
}

レスポンス定義

まずは、返却するjsonの構造に合わせてクラスを作成。

ErrorResponse.java
package com.example.springapi.application.resource;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Value;

/**
 * エラーレスポンスのマッピング用クラス(Json)
 */
@Value
public class ErrorResponse {
    @JsonProperty("Error")
    private Error error;

    public ErrorResponse(String message, String detail, String code) {
        this.error = new Error(message, detail, code);
    }

    @Value
    private class Error {
        @JsonProperty("Message")
        private final String message;
        @JsonProperty("Detail")
        private final String detail;
        @JsonProperty("Code")
        private final String code;
    }
}

独自例外を定義

ハンドルしやすいように独自例外を定義する。
今回は、操作しようとしたリソースが存在しない場合にthrowされる例外を定義する。

NotFoundException.java
package com.example.springapi.domain.exception;

/**
 * 操作しようとしたリソースが存在しない場合にthrowされるException
 */
public class NotFoundException extends RuntimeException {

    public NotFoundException(String message) {
        super(message);
    }
}

既存のコードを修正

参照時、削除時にリソースが存在しない場合、先程定義した例外をthrowする。
例外のthrowはインフラ層からthrowしてみる。(リソースの存在確認は実装に依存すると判断)
Spring BootとJPAでREST APIを実装する(インフラ層編) - Qiita

UserRepositoryImpl.java

public User findById(String id) {
    return this.userJpaRepository.findById(id)
            .orElseThrow(() -> new NotFoundException(id + " is not found."))
            .toDomainUser();
}

public void deleteById(String id) {
    try {
        this.userJpaRepository.deleteById(id);
    } catch (EmptyResultDataAccessException e) {
        // 削除しようとしたIDが存在しない
        throw new NotFoundException(e.getMessage());
    }
}

findByIdのインタフェースも合わせて修正しておく。
(リソースが存在しないということをOptionalで表現していたが、Exceptionで表現することができるため、Optionalは不要になる。)
以下Contollerから抜粋。

UserController.java
public User findById(@PathVariable("id") String id) {
    return this.userService.findById(id);
}

レスポンスフォーマットを変える

@RestControllerAdviceというアノテーションをつけ、ResponseEntityExceptionHandlerというクラスを継承したクラスを作成する。

ControllerExceptionHandler.java
package com.example.springapi.application.controller;

import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

/**
 * コントローラからthrowされるExceptionをハンドルするクラス
 */
@RestControllerAdvice
public class ControllerExceptionHandler extends ResponseEntityExceptionHandler {
}

継承したResponseEntityExceptionHandlerクラスがデフォルトでよしなに例外処理やってる正体。
いろいろやってるが、最終的にhandleExceptionInternalというメソッドを呼び出して、レスポンスを返しているので、それをこっちで上書きしちゃえばOK。

ControllerExceptionHandler.java
@Override
protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {
    if (!(body instanceof ErrorResponse)) {
        body = new ErrorResponse(status.getReasonPhrase(), "", "");
    }
    return new ResponseEntity<>(body, headers, status);
}

こんな感じ

% curl "http://localhost:8080/v1/users" -s -w '\nstatus code: %{http_code}\n'
{"Error":{"Message":"Method Not Allowed","Detail":"","Code":""}}
status code: 405

※ NoHandlerFoundException

この実装だと、存在しないパスへのアクセス時の例外をハンドルすることができない。

% curl "http://localhost:8080/v1" -s -w '\nstatus code: %{http_code}\n'
{"timestamp":"2018-07-27T09:58:24.897+0000","status":404,"error":"Not Found","message":"No message available","path":"/v1"}
status code: 404

これは、Spring Bootの仕様で、対応する処理(コントローラ)が存在しないパスへのアクセスは静的ファイルへのアクセスとみなされてしまい、ExceptionHandlerに処理が回ってこないためである。
静的ファイルへのマッピングをオフにすることで、回避することができる。

application.yml
spring:
  mvc:
    throw-exception-if-no-handler-found: true
  resources:
    add-mappings: false

独自の例外を追加する

先程定義した例外は、よしなにやってくれないので、処理を追加する。
@ExceptionHandlerで任意のExceptionをハンドルすることができる。

ControllerExceptionHandler.java
/**
 * 404
 *
 * @param ex      throwされたException
 * @param request the current request
 * @return エラーレスポンス
 */
@ExceptionHandler({NotFoundException.class})
public ResponseEntity<Object> handle404(NotFoundException ex, WebRequest request) {
    HttpHeaders headers = new HttpHeaders();
    ErrorResponse body = new ErrorResponse("Not Found", "", "");
    HttpStatus status = HttpStatus.NOT_FOUND;

    return this.handleExceptionInternal(ex, body, headers, status, request);
}

/**
 * 500
 *
 * @param ex      throwされたException
 * @param request the current request
 * @return エラーレスポンス
 */
@ExceptionHandler({Exception.class})
public ResponseEntity<Object> handle500(Exception ex, WebRequest request) {
    HttpHeaders headers = new HttpHeaders();
    ErrorResponse body = new ErrorResponse("Internal Server Error", "", "");
    HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;

    return this.handleExceptionInternal(ex, body, headers, status, request);
}

既存の処理をカスタムしてみる

バリデーションは引っかかった理由を書きたい。
ResponseEntityExceptionHandlerでよしなにやってる部分を上書きする。

ControllerExceptionHandler.java
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
    // validationに失敗したフィールドのリストを取得
    List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors();
    // レスポンスの"Detail"に格納するために、validationに失敗したフィールドと失敗理由を連結
    StringBuilder errorDetailStr = new StringBuilder();
    fieldErrors.forEach(fieldError ->
            errorDetailStr.append(fieldError.getField())
                    .append(": ")
                    .append(fieldError.getDefaultMessage())
                    .append("; ")
    );

    ErrorResponse body = new ErrorResponse("Bad Request", errorDetailStr.toString(), "");

    return this.handleExceptionInternal(ex, body, headers, status, request);
}
% curl -X POST "http://localhost:8080/v1/users" -H "Content-Type: application/json" -d '{"id": "", "value": "value"}' -s | jq

{
  "Error": {
    "Message": "Bad Request",
    "Detail": "id: must not be blank; ",
    "Code": ""
  }
}
58
64
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
58
64

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?