Spring Boot の REST API で Union Type を使う
はじめに
2月に株式会社ログラスにエンジニアとしてジョインした櫻井です。
バックエンドの開発が得意ですが、フロントエンドやインフラも触る雑食系エンジニアです。
この記事では、
- Spring Boot (& Kotlin) の REST API で Union Type を取り扱う方法
- springdoc-openapi を使って、TypeScript から扱いやすい形で APIスキーマ を公開する方法
を解説します。
Union Type ?
「いずれかの型」を表現する型です。
以下の例は、円(Circle)と長方形(Rectangle)のいずれかの型を表現した Shape 型の例です。
sealed class Shape
data class Circle(
val radius: Int,
) : Shape()
data class Rectangle(
val width: Int,
val height: Int,
) : Shape()
いずれかの型を型安全に扱いたいときに有用です。
UnionType の使用例については、UnionType を多用する TypeScript のドキュメントが参考になると思います。
また、少しハードルは高いですが使用例をガッツリ勉強したい方は、書籍「関数型ドメインモデリング」が非常にオススメです。
Union Type(書籍はF#のため、"OR"型、選択型と表現されていますが、大体同じものです) を使ったモデリングの例が多数登場し、読み終わる頃には Union Type なしでの設計を考えられないようになっていることでしょう。Spring Boot による API 実装
それでは、Spring Boot でAPIを実装していきましょう。Union Typeの例に出した Shape を登録できる ShapeController を実装していきます。
最終的に以下の構成を作成します。
言語は Kotlin を選択していますが、Java17 以降であれば Java でも sealed class で同様に実装可能です。
spring initializr を使ってプロジェクトを作成します。
$ mkdir union-type-example && cd $_
$ curl -s https://start.spring.io/starter.tgz \
-d type=gradle-project-kotlin \
-d bootVersion=3.4.4 \
-d language=kotlin \
-d javaVersion=17 \
-d groupId=com.example \
-d artifactId=union \
-d dependencies=web,validation | tar zxvf -
$ ./gradlew build
Spring Boot では、JSON <-> Class のマッピングに Jackson が使用されるため、Jackson 用のアノテーションをつけることで UnionType を実現できます。
@RestController
@RequestMapping("/shapes")
class ShapeController {
@PostMapping()
fun add(
@RequestBody @Validated shape: Shape,
): Shape {
return shape
}
}
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes(
JsonSubTypes.Type(value = Circle::class, name = "circle"),
JsonSubTypes.Type(value = Rectangle::class, name = "rectangle"),
)
sealed class Shape
data class Circle(
@field:Min(1)
val radius: Int,
) : Shape()
data class Rectangle(
@field:Min(1)
val width: Int,
@field:Min(1)
val height: Int,
) : Shape()
上記の定義により、
{
"type": "circle",
"radius": 2
}
または、
{
"type": "rectangle",
"width": 3,
"height": 4
}
のいずれかを受け付けるControllerが実装できます。
サーバーを起動して確認してみましょう
$ ./gradlew bootRun
curl -X POST http://localhost:8080/api/shapes \
-H "Content-Type: application/json" \
-d '{"type":"circle","radius":2}' | jq .
{
"type":"circle",
"radius":2
}
curl -X POST http://localhost:8080/api/shapes \
-H "Content-Type: application/json" \
-d '{"type":"rectangle","width": 3,"height":4}' | jq .
{
"type": "rectangle",
"width": 3,
"height": 4
}
動いていそうです。
簡単ですね。(アノテーションが少し冗長ですが)
springdoc-openapi を使ってAPIスキーマを公開
作成したAPIをフロントエンド(TypeScript)から利用するため openapi のスキーマを公開します。
openapi のスキーマ生成には、Spring Boot の Controller からスキーマを自動生成する springdoc-openapi を使用します。
依存関係に springdoc-openapi を追加します。
@@ -23,6 +23,7 @@
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
+ implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
依存関係を追加してから、再度サーバを起動するとhttp://localhost:8080/swagger-ui.html
からAPI定義を確認できるようになります。
定義した UnionType は、openapi 上では、One of に変換されて一見すると問題ないですが、このままでは TypeScript から扱いづらいです。
TypeScript 側コードから利用してみましょう。openapi から TypeScript コードの生成には openapi-generator を使用します。
$ mkdir client && cd $_
$ npm init
(略)
$ npm add @openapitools/openapi-generator-cli
$ npx openapi-generator-cli generate -i http://localhost:8080/v3/api-docs -g typescript-axios -o ./src/api --type-mappings=set=Array
生成したコードを使用して、circle を登録するコードを記述してみます。
type: 'cricle'
とすべきところで、間違えて'rectangle'
と radius というあり得ない組み合わせを指定していますが、タイプチェックをパスしてしまっています。
これは、type
の型が string
として扱われていることが原因で、このままでは Union Type のメリットである型安全性を享受できません。
TypeScript 上で型安全な Union Type を実現するためには、openapi (JsonSchema)上で、const keyword を使用する必要があります。
const keyword を使用するために、Spring Boot 側のコントローラーを以下にように変更します。
@@ -2,6 +2,7 @@
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
+import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.Min
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.PostMapping
@@ -28,11 +29,17 @@
data class Circle(
@field:Min(1)
val radius: Int,
-) : Shape()
+) : Shape() {
+ @field:Schema(_const = "circle")
+ val type = "circle"
+}
data class Rectangle(
@field:Min(1)
val width: Int,
@field:Min(1)
val height: Int,
-) : Shape()
+) : Shape() {
+ @field:Schema(_const = "rectangle")
+ val type = "rectangle"
+}
@Schema
アノテーションは、openapi に出力されるスキーマをカスタマイズするために使用するアノテーションです。(型の指定はエスケープハッチ的なAPIで、乱用すると openapi 自動生成の旨みがなくなるので注意しましょう。基本的に description や example などのメタ情報の付与だけに使うのがオススメです。)
変更後に Spring Boot を再起動して、client を再生成してみましょう。
$ ./gradlew bootRun
$ npx openapi-generator-cli generate -i http://localhost:8080/v3/api-docs -g typescript-axios -o ./src/api --type-mappings=set=Array
type
が Const となり、タイプチェックでもエラー(赤波線)が出ました!
これで クライアント(TypeScript)側から型安全に API を利用できます。
最終的なコード
package com.example.union.controller
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.Min
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/shapes")
class ShapeController {
@PostMapping()
fun add(
@RequestBody @Validated shape: Shape,
): Shape = shape
}
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes(
JsonSubTypes.Type(value = Circle::class, name = "circle"),
JsonSubTypes.Type(value = Rectangle::class, name = "rectangle"),
)
sealed class Shape
data class Circle(
@field:Min(1)
val radius: Int,
) : Shape() {
@field:Schema(_const = "circle")
val type = "circle"
}
data class Rectangle(
@field:Min(1)
val width: Int,
@field:Min(1)
val height: Int,
) : Shape() {
@field:Schema(_const = "rectangle")
val type = "rectangle"
}
plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25"
id("org.springframework.boot") version "3.4.4"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
最後に
Spring Boot の REST API で Union Type を扱う方法を紹介してきました。
最終的なコードはそれほど複雑ではありませんが、(冗長ではある)
ハマりポイントは多めなので、この記事が問題解決の助けになれば幸いです。
openapi などのスキーマを活用した API 開発での問題解決では、
- サーバー側コード(SpringBoot, Jackson)
- スキーマ(openapi, JsonSchema)
- クライアント側コード(openapi-generator, TypeScript)
のどこに問題があるのか特定して問題を絞り込むと解決しやすいと思います。
Discussion