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

[Spring Boot + MySQLでシンプルなWeb REST APIサーバを実装する - Qiita](https://qiita.com/YutaKase6/items/007f6b7d12d342c1330c)

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

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

# After
```json
{
  "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](https://qiita.com/YutaKase6/items/50661538166900146eeb#userrepositoryimpljava)

```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": ""
  }
}
```