Spring Boot 3.0 で入った RFC7807 サポートを色々試す

先日、Spring Boot 3.0 が[GA](https://spring.io/blog/2022/11/24/spring-boot-3-0-goes-ga)になりました。実に5年ぶりのメジャーバージョンアップだったようです。めでたいですね。
Java17&Java EE9やGraalVMによるnativeイメージサポートなど大きな機能強化以外にも様々な新機能が入っています。
この記事では、CHANGELOGで見つけたちょっと変わり種の機能 RFC7807サポートについて試した結果を紹介します。

## RFC7807 とは？
[RFC 7807 Probrem Detail for HTTP APIs](https://www.rfc-editor.org/rfc/rfc7807)

HTTP APIのエラーレスポンス形式を標準化したRFCです。REST APIを作るたびにエラー形式考えるのがめんどくさい人のための標準仕様になります。 ```application/problem+json``` で以下のようなレスポンスを返却します。
```json
   {
    "type": "https://example.com/probs/out-of-credit",
    "title": "You do not have enough credit.",
    "detail": "Your current balance is 30, but that costs 50.",
    "instance": "/account/12345/msgs/abc"
   }
```

RFC7807自体の詳しい説明は省略します。Qiitaにも良い紹介記事があがっているので以下記事などをご確認ください。

https://qiita.com/akkino_D-En/items/a54bdf5dadc2daba1ab4

## Spring Bootのサポート内容
https://docs.spring.io/spring-framework/docs/6.0.0/reference/html/web.html#mvc-ann-rest-exceptions
> The following are the main abstractions for this support:
> + ProblemDetail — representation for an RFC 7807 problem detail; a simple container for both standard fields defined in the spec, and for non-standard ones.
> + ErrorResponse — contract to expose HTTP error response details including HTTP status, response headers, and a body in the format of RFC 7807; this allows exceptions to encapsulate and expose the details of how they map to an HTTP response. All Spring MVC exceptions implement this.
> + ErrorResponseException — basic ErrorResponse implementation that others can use as a convenient base class.
> + ResponseEntityExceptionHandler — convenient base class for an @ControllerAdvice that handles all Spring MVC exceptions, and any ErrorResponseException, and renders an error response with a body.

### 意訳
+ ProblemDetailはRFC7807 problem detailを表現したシンプルなコンテナクラスだよ
+ ErrorResponseはHTTPステータス、ヘッダー、本文を含むエラーレスポンスを RFC 7807 形式で公開する契約（interfaceのこと？）だよ。全てのSpringMVC例外はこれを実装するよ
+ ErrorResponseExceptionはErrorResponseを実装するのに便利なbaseクラスだよ
+ ResponseEntityExceptionHandlerは全てのMVC例外とErrorResponseExceptionをエラーレスポンスにレンダリングする便利な```@ControllerAdvice```用のbaseクラスだよ

ResponseEntityExceptionHandlerを使うと、Springが出す例外とErrorResponseExceptionをいい感じにRFC7807形式にしてくれるようです。
早速実装してみましょう。

### 環境
以下の環境で確認しました。Spring Boot 3.0はJDK17以上でしか動作しないのでご注意ください。
+ macOS Monterey 12.6
+ JDK Corretto-17.0.5

spring initializrでプロジェクトを作成します。依存はwebとvalidationを追加。
```bash
$ mkdir rfc7807-example && cd $_
$ curl -s https://start.spring.io/starter.tgz \
  -d bootVersion=3.0.0 \
  -d javaVersion=17
  -d groupId=com.example \
  -d artifactId=rfc7807 \
  -d dependencies=web,validation | tar zxvf -
$ ./gradlew build
```

### エラーを返すControllerを定義する
呼ぶと必ず例外が発生する /error を定義します。
```java:src/main/java/com/example/rfc7807/controller/ErrorController.java
@RestController
public class ErrorController {
  @GetMapping("/error")
  public void error() {
    throw new ErrorResponseException(HttpStatus.INTERNAL_SERVER_ERROR);
  }
}
```
ドキュメントに従い、ResponseEntityExceptionHandlerを継承したクラスにControllerAdviceをセットします。カスタマイズしないためクラスの中身は空です。
```java:src/main/java/com/example/rfc7807/controller/MyExceptionHandler.java
@ControllerAdvice
public class MyExceptionHandler extends ResponseEntityExceptionHandler {}
```

（追記）
自前のExceptionHandlerを定義する代わりにapplication.ymlでも有効化できるようです。[doc](https://docs.spring.io/spring-boot/docs/current-SNAPSHOT/reference/html/web.html#web.servlet.spring-mvc.error-handling)
```yaml: src/main/resources/application.yml
spring.mvc.problemdetails.enabled: true
```
（追記終わり）

./gradlew bootRun で起動して curl で動作確認します。
```bash
$ curl -s -D - http://localhost:8080/error
HTTP/1.1 500
Content-Type: application/problem+json

{"type":"about:blank","title":"Internal Server Error","status":500,"instance":"/error-response"}
```
いいですね。ちゃんとContent-Type: application/problem+jsonでRFC7807形式で返却されています。
titleはstatusから自動でデフォルトのタイトルを作ってくれているようです。typeはエラー用のドキュメントのURLを返す項目ですが、返すものがない場合は about:blank を返す[仕様](https://www.rfc-editor.org/rfc/rfc7807#section-3.1)でデフォルトも about:blankになっているようです。

エラーレスポンスの中身をカスタマイズしたい場合は以下のようにProblemDetailをセットします。
```diff_java:src/main/java/com/example/rfc7807/controller/ErrorController.java
@RestController
public class ErrorController {
+  @GetMapping("/error-detail")
+  public void errorDetail() {
+    var problemDetail = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
+    problemDetail.setType(URI.create("https://API仕様書のURL/internal-server-error"));
+    problemDetail.setDetail("エラーの詳細");
+    throw new ErrorResponseException(HttpStatus.INTERNAL_SERVER_ERROR, problemDetail, null);
+  }
}
```
実行結果は以下です。（ヘッダーは一緒なので省略）
```bash
$ curl -s http://localhost:8080/error-detail | jq .
{
  "type": "https://API仕様書のURL/internal-server-error",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "エラーの詳細",
  "instance": "/error-detail"
}
```

### Bean Validationとの組み合わせ
REST APIを作るときには[Bean Validation](https://docs.spring.io/spring-framework/docs/6.0.0/reference/html/core.html#validation-beanvalidation)を入力チェックに使うことが多いので、Bean Validationと組み合わせた時のエラーレスポンスを確認してみます。

以下のEntityを定義します。せっかくのJava17なのでrecordで定義します。
```java:src/main/java/com/example/rfc7807/controller/Item.java
public record Item(
  Integer id,
  @NotBlank @Size(min = 2, max = 100)
  String name
) {
}
```
Itemはidとnameを持ち、name2~100文字で必須です。

```java:src/main/java/com/example/rfc7807/controller/ItemController.java
public class ItemController {
  @PostMapping("/items")
  public ResponseEntity<?> add(@RequestBody @Valid Item item) {
    // 永続化とid採番は省略
    var location = ServletUriComponentsBuilder
            .fromCurrentRequest().path("/0").toUri();
    return ResponseEntity.created(location).build;
  }
}
```

実行してみます。

```bash
## 正常
$ curl -s -X POST -H "Content-Type: application/json" http://localhost:8080/items \
  -d '{"name": "ItemName"}' -D -
HTTP/1.1 201
Location: http://localhost:8080/items/0

## エラー（name未指定）
$ curl -s -X POST -H "Content-Type: application/json" http://localhost:8080/items \
  -d '{}' | jq .
{
  "type": "about:blank",
  "title": "Bad　Request",
  "status": 400,
  "detail": "Invalid request content.",
  "instance": "/items"
}

## エラー（nameが2文字未満）
$ curl -s -X POST -H "Content-Type: application/json" http://localhost:8080/items \
  -d '{"name": "n"}' | jq .
{
  "type": "about:blank",
  "title": "Bad　Request",
  "status": 400,
  "detail": "Invalid request content.",
  "instance": "/items"
}
```

特に追加の設定なしで、入力チェックエラーを自動的に400 Bad Requestのエラー内容に変換してくれました。ただし、detailは固定値でどの入力チェックでエラーになったかは分かりません。

[ドキュメント](https://docs.spring.io/spring-framework/docs/6.0.0/reference/html/web.html#mvc-ann-rest-exceptions-i18n)によると、エラーメッセージはMessageSourceを使ってカスタマイズ可能で、入力チェックエラー（MethodArgumentNotValidException） の場合、{1}にフィールドエラーのメッセージが入るようです。

Spring Bootでは、resources/message.propertiesを作成すると自動的にMessageSourceとして読み込んでくれるので設定します。
```properties:src/main/resources/message.properties
problemDetail.org.springframework.web.bind.MethodArgumentNotValidException=Invalid request content. {1}
```

実行します。

```bash
## エラー（name未指定）
$ curl -s -X POST -H "Content-Type: application/json" http://localhost:8080/items \
  -d '{}' | jq .
{
  "type": "about:blank",
  "title": "Bad　Request",
  "status": 400,
  "detail": "Invalid request content. [name: '空白は許可されていません']",
  "instance": "/items"
}

## エラー（nameが2文字未満）
$ curl -s -X POST -H "Content-Type: application/json" http://localhost:8080/items \
  -d '{"name": "n"}' | jq .
{
  "type": "about:blank",
  "title": "Bad　Request",
  "status": 400,
  "detail": "Invalid request content. [name: '2 から 100 の間のサイズにしてください']",
  "instance": "/items"
}
```

エラーとなったフィールド名とエラー内容（それぞれ```@Blank```と```@Size```のデフォルトメッセージ？）が表示されました。簡単ですね。
個人的にはデフォルトでここまで表示してくれると助かるのですが、意図せず入力チェックのショボさがバレないようにする配慮でしょうか。（例えば```@Pattern```のチェックで正規表現の実装が間違っていると、不正な値が入力できてしまうことがバレる）

## まとめ
Spring Boot 3.0で新しく導入されたRFC7807サポートを紹介しました。
最小限の設定ファイル（この記事では2ファイル）でいい感じのエラーレスポンスを定義できるのは楽ですね。API仕様書の方にもRFCのリンクとレスポンス例を貼るだけで済むので省力化できそうです。
元々RFC7807自体には興味があったのですが、自分で全部実装するのは面倒で使ったことはありませんでした。今回、Springが公式にサポートしてくれたことで採用しやすくなったんじゃないでしょうか。