Kotlin + Spring Bootでリクエスト本文のバリデーションが効かない場合の対処

普段KotlinでSpring Bootアプリケーションを書いています。

で、 `@NotBlank` や `@Size(max = 10)` などでバリデーションを指定できると様々な記事で書かれているわけですが、これがいつも効かなくて困っていました。やっとこさ解決できたので、その方法をまとめておきます。

## サンプルの仕様

以下のような仕様とします。

- Personというクラスがあり、nameを持つ
- nameはPersonName型として指定する（値オブジェクト的な）
- nameは空文字列を拒否し、10文字以下とする
- Personの登録を行うエンドポイントを持ち、JSONで受け取る

## サンプルの仕様にもとづいたコード

Personクラスの定義や、PersonName用のJsonDeserializerの定義は省略しています。

### PersonName.kt

```kotlin
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size

data class PersonName(
  @NotBlank
  @Size(max = 10)
  val value: String
)
```

### PersonRegisterRequestBody.kt

```kotlin
class PersonRegisterRequestBody(
  val name: PersonName
)
```

### PersonController.kt

```kotlin
import javax.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.validation.BindingResult
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController

@RestController
class PersonController {

  @PostMapping("/register")
  fun register(
    @RequestBody @Valid requestBody: PersonRegisterRequestBody,
    result: BindingResult
  ): ResponseEntity<*> {
    if (result.hasErrors()) {
      throw // ...
    }
    // ...
  }
}
```

## 「サンプルの仕様にもとづいたコード」の問題点

大体こんな感じのコードだと思います。ポイントとしては:

- バリデーション対象にBean Validationの `@NotBlank` アノテーションや `@Size` アノテーションを付与し、バリデーションのルールを定義
- 受け取るリクエスト本文に `@Valid` アノテーション（あるいは `@Validated` アノテーション）を付与
- BindingResultでバリデーション結果を受け取る

というところでしょうか。

しかし、実際にこのSpring Bootアプリケーションを起動し、対象のエンドポイントに `{"name": ""}` なJSONをぶん投げると、 `result.hasErrors()` はfalseになります。パッと見 `@NotBlank` アノテーションが効いていないように見えます。

## 解決法

いくつか問題があるので、ひとつずつ潰していきます。

### そもそもバリデーションを行っていない

バリデーションのルールはたしかに定義されているのですが、これだけではバリデーションが実行されません。そりゃBindingResultも常にエラーなしになるわ。

以下の依存をbuild.gradleに追加しましょう（別のビルドツール使っている方はよしなに）。

```gradle
implementation "org.springframework.boot:spring-boot-starter-validation:$springBootVersion"
```

正直これが一番ハマったかもしれない。

ちなみにこの依存を追加していなくても、使える人は使える模様。Spring Bootの仕様を読む限り、JSR-303実装のバリデーター（Hibernateバリデーターなど）がクラスパス上にあれば自動的にバリデーションが有効になるっぽい？実際、[spring-boot-starter-validationもhibernate-validatorを依存に追加しているだけ](https://github.com/spring-projects/spring-boot/blob/master/spring-boot-project/spring-boot-starters/spring-boot-starter-validation/build.gradle)。

### アノテーションの対象が間違っている

Spring Bootの仕様だと思いますが、バリデーションのルールは基本フィールドに対して付与する必要があります。

しかし、Kotlinのclassのコンストラクターにvalを指定した場合、それはコンストラクターのパラメーターでもあるし、フィールドでもあるし、ゲッターでもあります。なので単にアノテーションを付与すると、開発者が期待する対象と実際の対象が食い違うことが起こり得ます。実際、今回の `@NotBlank` などのアノテーションはコンストラクターのパラメーターに対して付与されちゃいます。

Kotlinには[Annotation User-site Targets](https://kotlinlang.org/docs/reference/annotations.html#annotation-use-site-targets)という仕組みがあり、何に対するアノテーションなのか明示的に記述できます。今回はフィールドに対するアノテーションなので、以下のようにしましょう。

```kotlin
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size

data class PersonName(
  @field: NotBlank
  @field: Size(max = 10)
  val value: String
)
```

いずれのアノテーションも同一の対象なので、まとめて以下のように書いてもOKです。

```kotlin
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size

data class PersonName(
  @field: [NotBlank Size(max = 10)]
  val value: String
)
```

### 値オブジェクトのバリデーションが実行されない

サンプルの仕様にもとづいたコードのように、リクエスト本文で受け取る型をPersonNameのような値オブジェクトめいたものにし、そちらのバリデーションのルールを流用しちゃおう、みたいなやつは、先述したコードでは動作しません。

なぜなら「PersonRegisterRequestBodyが受け取るPersonNameは有効であるべき」という指定が抜けているからです。結論から言うと以下のようにします。フィールドを対象にすることを忘れずに！

```kotlin
import javax.validation.Valid

class PersonRegisterRequestBody(
  @field: Valid
  val name: PersonName
)
```

なるほど、たしかにコントローラーのメソッドの引数には `@Valid` アノテーションを付与し、「有効であるべき」という指定をしているんだから、それをクラスのフィールドに対しても行わないといかんよな、という感じで納得できる（感想）。

まあStringなどを直接受け取るのであれば別に頭悩ませることではないと思いますが！

## サンプルの仕様にもとづいた正しいコード

というわけで解決法を適用した正しいコードは下記の通り。これでめでたし。

### build.gradle

```gradle
dependencies {
  // ...

  implementation "org.springframework.boot:spring-boot-starter-validation:$springBootVersion"

  // ...
}
```

### PersonName.kt

```kotlin
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size

data class PersonName(
  @field: NotBlank
  @field: Size(max = 10)
  val value: String
)
```

### PersonRegisterRequestBody.kt

```kotlin
import javax.validation.Valid

class PersonRegisterRequestBody(
  @field: Valid
  val name: PersonName
)
```

### PersonController.kt

変更なし。