Spring Boot エラーページの最低限のカスタマイズ (ErrorController インターフェースの実装)

## 概要
- Spring Boot のデフォルト設定では 404 Not Found や 500 Internal Server Error が発生した際に Whitelabel Error Page や JSON が返される
  - Web ブラウザからのアクセスには HTML を返すようになっている
  - curl などのマシンクライアントからのアクセスには JSON を返すようになっている
- 独自に設置した error.html や error/404.html では JSON が返されるのを止めることができない
- ErrorController インターフェースを実装したクラスを用意してエラー時に返す HTML や JSON をカスタマイズしたほうが良い
- 今回の動作確認環境: Java 11 + Spring Boot 2.2.1

## 最低限のエラーページ実装例

エラー発生時はすべて 404 Not Found として表示する。

### ErrorController インターフェースの最低限の実装クラス

```java
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;

/**
 * Web アプリケーション全体のエラーコントローラー。
 * ErrorController インターフェースの実装クラス。
 */
@Controller
@RequestMapping("/error") // エラーページへのマッピング
public class MySimpleErrorController implements ErrorController {

  /**
   * エラーページのパスを返す。
   *
   * @return エラーページのパス
   */
  @Override
  public String getErrorPath() {
    return "/error";
  }

  /**
   * レスポンス用の ModelAndView オブジェクトを返す。
   *
   * @param req リクエスト情報
   * @param mav レスポンス情報
   * @return HTML レスポンス用の ModelAndView オブジェクト
   */
  @RequestMapping
  public ModelAndView error(HttpServletRequest req, ModelAndView mav) {

    // どのエラーでも 404 Not Found にする
    // 必要に応じてステータコードや出力内容をカスタマイズ可能
    mav.setStatus(HttpStatus.NOT_FOUND);

    // ビュー名を指定する
    // Thymeleaf テンプレート src/main/resources/templates/error.html を使用
    mav.setViewName("error");

    return mav;
  }
}
```

### Thymeleaf HTML テンプレートファイル src/main/resources/templates/error.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>404 Not Found</title>
</head>
<body>
<h1>404 Not Found</h1>
</body>
</html>
```

### 動作検証

エラー発生時にも 500 Internal Server Error 等ではなく 404 Not Found をクライアントに返すようになった。
また、 curl などのマシンクライアントからのアクセスにも JSON ではなく HTML を返すようになった。

```
$ curl --include http://localhost:8080/
HTTP/1.1 404 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: text/html;charset=UTF-8
Content-Language: ja-JP
Transfer-Encoding: chunked
Date: Mon, 18 Nov 2019 13:33:52 GMT

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>404 Not Found</title>
</head>
<body>
<h1>404 Not Found</h1>
</body>
</html>
```

## 詳細なエラー情報を出力するエラーページ実装例

詳細なエラー情報を構築し、クライアントに返す内容を取捨選択したりログに出力したりできるようにする。

### DefaultErrorAttributes クラス

Spring Boot の DefaultErrorAttributes クラスを使用すると詳細なエラー情報を構築しやすい。

[DefaultErrorAttributes \(Spring Boot Docs 2\.2\.1\.RELEASE API\)](https://docs.spring.io/spring-boot/docs/2.2.1.RELEASE/api/org/springframework/boot/web/servlet/error/DefaultErrorAttributes.html)

> Default implementation of ErrorAttributes. Provides the following attributes when possible:
> 
> ・timestamp - The time that the errors were extracted
> ・status - The status code
> ・error - The error reason
> ・exception - The class name of the root exception (if configured)
> ・message - The exception message
> ・errors - Any ObjectErrors from a BindingResult exception
> ・trace - The exception stack trace
> ・path - The URL path when the exception was raised

### ErrorController インターフェースの実装クラス

```java
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.RequestDispatcher;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;

/**
 * Web アプリケーション全体のエラーコントローラー。
 * ErrorController インターフェースの実装クラス。
 */
@Controller
@RequestMapping("/error") // エラーページへのマッピング
public class MyDetailErrorController implements ErrorController {

  /**
   * エラーページのパスを返す。
   *
   * @return エラーページのパス
   */
  @Override
  public String getErrorPath() {
    return "/error";
  }

  /**
   * エラー情報を抽出する。
   *
   * @param req リクエスト情報
   * @return エラー情報
   */
  private static Map<String, Object> getErrorAttributes(HttpServletRequest req) {
    // DefaultErrorAttributes クラスで詳細なエラー情報を取得する
    ServletWebRequest swr = new ServletWebRequest(req);
    DefaultErrorAttributes dea = new DefaultErrorAttributes(true);
    return dea.getErrorAttributes(swr, true);
  }

  /**
   * レスポンス用の HTTP ステータスを決める。
   *
   * @param req リクエスト情報
   * @return レスポンス用 HTTP ステータス
   */
  private static HttpStatus getHttpStatus(HttpServletRequest req) {
    // HTTP ステータスを決める
    // ここでは 404 以外は全部 500 にする
    Object statusCode = req.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
    HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
    if (statusCode != null && statusCode.toString().equals("404")) {
      status = HttpStatus.NOT_FOUND;
    }
    return status;
  }

  /**
   * HTML レスポンス用の ModelAndView オブジェクトを返す。
   *
   * @param req リクエスト情報
   * @param mav レスポンス情報
   * @return HTML レスポンス用の ModelAndView オブジェクト
   */
  @RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
  public ModelAndView myErrorHtml(HttpServletRequest req, ModelAndView mav) {

    // エラー情報を取得
    Map<String, Object> attr = getErrorAttributes(req);

    // HTTP ステータスを決める
    HttpStatus status = getHttpStatus(req);

    // HTTP ステータスをセットする
    mav.setStatus(status);

    // ビュー名を指定する
    // Thymeleaf テンプレートの場合は src/main/resources/templates/error.html
    mav.setViewName("error");

    // 出力したい情報をセットする
    mav.addObject("status", status.value());
    mav.addObject("timestamp", attr.get("timestamp"));
    mav.addObject("error", attr.get("error"));
    mav.addObject("exception", attr.get("exception"));
    mav.addObject("message", attr.get("message"));
    mav.addObject("errors", attr.get("errors"));
    mav.addObject("trace", attr.get("trace"));
    mav.addObject("path", attr.get("path"));

    return mav;
  }

  /**
   * JSON レスポンス用の ResponseEntity オブジェクトを返す。
   *
   * @param req リクエスト情報
   * @return JSON レスポンス用の ResponseEntity オブジェクト
   */
  @RequestMapping
  public ResponseEntity<Map<String, Object>> myErrorJson(HttpServletRequest req) {

    // エラー情報を取得
    Map<String, Object> attr = getErrorAttributes(req);

    // HTTP ステータスを決める
    HttpStatus status = getHttpStatus(req);

    // 出力したい情報をセットする
    Map<String, Object> body = new HashMap();
    body.put("status", status.value());
    body.put("timestamp", attr.get("timestamp"));
    body.put("error", attr.get("error"));
    body.put("exception", attr.get("exception"));
    body.put("message", attr.get("message"));
    body.put("errors", attr.get("errors"));
    body.put("trace", attr.get("trace"));
    body.put("path", attr.get("path"));

    // 情報を JSON で出力する
    return new ResponseEntity<>(body, status);
  }
}
```

### Thymeleaf HTML テンプレートファイル src/main/resources/templates/error.html

ErrorController インターフェース実装クラスで指定した値を出力する。

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>/error</title>
</head>
<body>
<div th:text="'timestamp: ' + ${timestamp}"></div>
<div th:text="'status: ' + ${status}"></div>
<div th:text="'error: ' + ${error}"></div>
<div th:text="'exception: ' + ${exception}"></div>
<div th:text="'message: ' + ${message}"></div>
<div th:text="'errors: ' + ${errors}"></div>
<div th:text="'trace: ' + ${trace}"></div>
<div th:text="'path: ' + ${path}"></div>
</body>
</html>
```

### 動作検証

500 Internal Server Error を HTML で返す例。
詳細なエラー情報が HTML に埋め込まれている。

```
$ curl --include -H "accept: text/html" http://localhost:8080/sample
HTTP/1.1 500 
Content-Type: text/html;charset=UTF-8
Content-Language: ja-JP
Transfer-Encoding: chunked
Date: Mon, 18 Nov 2019 13:55:21 GMT
Connection: close

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>/error</title>
</head>
<body>
<div>timestamp: Mon Nov 18 22:55:21 JST 2019</div>
<div>status: 500</div>
<div>error: Internal Server Error</div>
<div>exception: java.lang.RuntimeException</div>
<div>message: This is a sample exception.</div>
<div>errors: null</div>
<div>trace: java.lang.RuntimeException: This is a sample exception.
	at com.example.demo.DemoApplication.index(DemoApplication.java:18)
(以下略)
```

500 Internal Server Error を JSON で返す例。
詳細なエラー情報が JSON に含められている。

```
$ curl --include http://localhost:8080/sample
HTTP/1.1 500 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 18 Nov 2019 13:55:31 GMT
Connection: close

{"exception":"java.lang.RuntimeException","path":"/sample","trace":"java.lang.RuntimeException: This is a sample exception.\n\tat com.example.demo.DemoApplication.index(DemoApplication.java:18)\n\t(中略)java.base/java.lang.Thread.run(Thread.java:834)\n","error":"Internal Server Error","message":"This is a sample exception.","errors":null,"status":500,"timestamp":"2019-11-18T13:55:31.644+0000"}
```

## 参考資料
- [Spring Boot の Whitelabel Error Page と JSON レスポンス \- Qiita](https://qiita.com/niwasawa/items/0ea9901b31099b08c631)
- [Spring Boot で 404 Not Found などのエラーが発生した際の表示をカスタマイズする \- Qiita](https://qiita.com/niwasawa/items/f3479ef16efa488039fb)