Spring Boot + Kotlin でパス変数を data class にマッピングする

## 結論

- companion object に、String を引数に取るファクトリーメソッドを定義すれば良い
- メソッド名は valueOf, of, from のいずれか
- メソッドには `@JvmStatic` をつける

## 検証環境

```
Spring 6.1.6
Spring Boot 3.2.5
Kotlin 1.9.23
```


## やりたいこと

構造を持ったデータクラスをパス変数として受け取りたい。

```kotlin:コントローラー
@RestController
class ClassroomController {
	@GetMapping("/classrooms/{id}")
	fun read(@PathVariable id: ClassroomId): Classroom {
		// 略
	}
}
```

```kotlin:メソッド引数の型：（年、組）を表すクラス
data class ClassroomId(val grade: Int, val group: String)
```

`/classrooms/3-A` に対するリクエストでは `ClassroomId(3, "A")` を引数で受け取るイメージです。Custom Editor などで対応可能ではあるものの面倒なので、なにか簡単な方法はないものかと調べていたところ、思いの外簡単に実現できることを知りました。

## PathVariable の変換ルール

パス変数は `@PathVariable` を付けた引数で受け取れますが、大半の型変換は Spring がよしなにやってくれます。

引数の型に対応する Converter が存在しない場合、Spring Boot のデフォルトでは `ObjectToObjectConverter` で変換を行っているようです。
このクラスでは次の順に変換を試みます。

1. 変換後のクラスに存在するファクトリーメソッド（`String` を引数に取るもの）  
※メソッド名は `valueOf`、`of`、`from` のいずれか
2. 変換後のクラスのコンストラクタ（`String` を引数に取るもの）

https://docs.spring.io/spring-framework/docs/4.3.22.RELEASE_to_4.3.23.RELEASE/Spring%20Framework%204.3.23.RELEASE/org/springframework/core/convert/support/ObjectToObjectConverter.html

## Kotlin の場合の注意

ファクトリーメソッドを追加すれば良いのですが、次のようにしても変換されません。

```kotlin
data class ClassroomId(val grade: Int, val group: String) {
	companion object {
        /**
         * [String] から [ClassroomId] を生成するファクトリーメソッド。
         *
         * たとえば "3-A" のような値を受け取る。
         */
		fun valueOf(stringValue: String): ClassroomId {
			val (grade, group) = stringValue.split("-")
			return ClassroomId(grade.toInt(), group)
		}
	}
}
```

companion object で定義した `valueOf` メソッドを Java から呼び出すには次のようになります。

```java
ClassroomId.Companion.valueOf("3-A")
```

Spring は次のようなファクトリーメソッドを探すので、見つけてもらえないのですね。

```java
ClassroomId.valueOf("3-A")
```

この問題に対処するには `@JvmStatic` を使います。`valueOf` メソッドにアノテーションをつければ、`ClassroomId` の static メソッドも生成されるので、Java からは以下どちらの方法でも扱えるようになります。

```java
ClassroomId.valueOf("3-A")
ClassroomId.Companion.valueOf("3-A")
```

https://kotlinlang.org/docs/java-to-kotlin-interop.html#static-methods

## バリデーション

data class にマッピングできるともう一つ嬉しいことがあります。

ファクトリーメソッドで分解しておけば、data class の各プロパティに対して Bean Validation が適用できます。たとえば1年A組〜3年E組以外をエラーにするなら次のようになります。

```kotlin
data class ClassroomId(
    @field:Min(1)
    @field:Max(3)
    val grade: Int,

    @field:Pattern(regexp = "[A-E]")
    val group: String
) {
    companion object {
        @JvmStatic
        fun valueOf(stringValue: String): ClassroomId {
            val (grade, group) = stringValue.split("-")
            return ClassroomId(grade.toInt(), group)
        }
    }
}
```

## 参考



| ソースコード | 説明 |
|:-- | :-- |
|[DefaultConversionService.java](https://github.com/spring-projects/spring-framework/blob/6.1.x/spring-core/src/main/java/org/springframework/core/convert/support/DefaultConversionService.java)| デフォルトで利用される型変換サービス。ここで登録されている Converter はデフォルトで利用可能。 |
| [ObjectToObjectConverter.java#L184-L200](https://github.com/spring-projects/spring-framework/blob/6.1.x/spring-core/src/main/java/org/springframework/core/convert/support/ObjectToObjectConverter.java#L184-L200) | `ObjectToObjectConverter` の中で変換に使うファクトリーメソッドを探している部分。 |
| [DefaultConversionServiceTests.java#L800-L809](https://github.com/spring-projects/spring-framework/blob/6.1.x/spring-core/src/test/java/org/springframework/core/convert/converter/DefaultConversionServiceTests.java#L800-L809) | `valueOf` で変換されることのテスト。 |