📋

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 のドキュメントが参考になると思います。
https://typescriptbook.jp/reference/values-types-variables/union
https://typescriptbook.jp/reference/values-types-variables/discriminated-union

また、少しハードルは高いですが使用例をガッツリ勉強したい方は、書籍「関数型ドメインモデリング」が非常にオススメです。
https://asciidwango.jp/post/754242099814268928/関数型ドメインモデリング
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 を実現できます。

./src/main/kotlin/com/example/union/controller/ShapeController.kt
@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 を追加します。

./build.gradle.kts
@@ -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定義を確認できるようになります。
swagger-ui.html

定義した 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 を登録するコードを記述してみます。
vscode

type: 'cricle'とすべきところで、間違えて'rectangle'と radius というあり得ない組み合わせを指定していますが、タイプチェックをパスしてしまっています。

これは、type の型が string として扱われていることが原因で、このままでは Union Type のメリットである型安全性を享受できません。

TypeScript 上で型安全な Union Type を実現するためには、openapi (JsonSchema)上で、const keyword を使用する必要があります。

const keyword を使用するために、Spring Boot 側のコントローラーを以下にように変更します。

./src/main/kotlin/com/example/union/controller/ShapeController.kt
@@ -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

swagger-ui.html

vscode

type が Const となり、タイプチェックでもエラー(赤波線)が出ました!
これで クライアント(TypeScript)側から型安全に API を利用できます。

最終的なコード
./src/main/kotlin/com/example/union/controller/ShapeController.kt
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"
}
./build.gradle.kts
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