Ktor のバグに遭遇した話

sya-ri

Souya Ichikawa (sya-ri)

  • フルスタックエンジニア
    • YUMEMI Part of Accenture Song
    • Kotlin, TypeScript, React, AWS, ...
  • コミュニティ活動
    • Kotlin愛好会
    • try! Swift Tokyo
Ktor
  • Kotlin 製の Web フレームワーク
  • Spring Boot より薄くて、小さく始めやすい
  • プラグインを組み合わせて機能を拡張できる
fun main() {
    embeddedServer(Netty, port = 8000) {
        routing {
            get("/") {
                call.respondText("Hello, world!") 
            }
        }
    }.start(wait = true)
}

例えばこんなプラグインがある

Authentication
認証・認可を扱う
ContentNegotiation
JSON などの変換を扱う
Compression
レスポンス圧縮を行う
CORS
クロスオリジン設定を行う
CallLogging
リクエストログを出す
StatusPages
例外やエラー応答をまとめて扱う
WebSockets
WebSocket 通信を扱う
Resources
型安全なルーティングを書ける
OpenAPI
API 仕様の生成や公開に使う

例えばこんなプラグインがある

Authentication
認証・認可を扱う
ContentNegotiation
JSON などの変換を扱う
Compression
レスポンス圧縮を行う
CORS
クロスオリジン設定を行う
CallLogging
リクエストログを出す
StatusPages
例外やエラー応答をまとめて扱う
WebSockets
WebSocket 通信を扱う
Resources
型安全なルーティングを書ける
OpenAPI 👈 問題になったのはこれ
API 仕様の生成や公開に使う

OpenAPI の生成

Kotlin
@Serializable
data class User(val name: String)

get("/users") {
    call.respond(listOf(User("sya-ri")))
}.describe {
    summary = "Get users"
    description = "Retrieves a list of users."
    responses {
        HttpStatusCode.OK {
            description = "A list of users"
            schema = jsonSchema<List<User>>()
        }
    }
}
Generated OpenAPI
paths:
  /users:
    get:
      summary: Get users
      description: Retrieves a list of users.
      responses:
        "200":
          description: A list of users
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/User"
components:
  schemas:
    User:
      type: object
      properties:
        name:
          type: string
      required:
        - name

表現したいデータ

Page
チームの作業メモ
今週のやることを整理しておく
画像
確認ポイント
  • デザイン案を確認する
  • レビュー観点を書き出す

パーツとして考える

UI イメージ
Heading (level = 1)
Text
Image
Heading (level = 2)
List
パーツ
Heading(level = 1)
Text
TwoColumnRow(
  left  = [Image],
  right = [
    Heading(level = 2),
    List
  ]
)
UI イメージ
Page
チームの作業メモ
今週のやることを整理しておく
Image
確認ポイント
  • デザイン案を確認する
  • レビュー観点を書き出す
JSON
{
  "blocks": [
    {
      "type": "heading",
      "text": "チームの作業メモ",
      "level": 1
    },
    {
      "type": "text",
      "text": "今週のやることを整理しておく"
    },
    {
      "type": "twoColumnRow",
      "left": [
        {
          "type": "image",
          "imageUrl": "https://example.com/mock.png"
        }
      ],
      "right": [
        { "type": "heading", "text": "確認ポイント", "level": 2 },
        {
          "type": "list",
          "items": [
            "デザイン案を確認する",
            "レビュー観点を書き出す"
          ]
        }
      ]
    }
  ]
}
@Serializable
sealed interface Block

@Serializable
data class Heading(
    val text: String,
    val level: Int,
) : Block

@Serializable
data class Text(
    val text: String,
) : Block

@Serializable
data class ListItems(
    val items: List<String>,
) : Block

@Serializable
data class Image(
    val imageUrl: String,
) : Block

@Serializable
data class TwoColumnRow(
    val left: List<Block>,
    val right: List<Block>,
) : Block
val blocks = listOf(
    Heading("チームの作業メモ", 1),
    Text("今週のやることを整理しておく"),
    TwoColumnRow(
        left = listOf(
            Image("https://example.com/mock.png"),
        ),
        right = listOf(
            Heading("確認ポイント", 2),
            ListItems(
                items = listOf(
                    "デザイン案を確認する",
                    "レビュー観点を書き出す",
                ),
            ),
        ),
    ),
)

ビルドエラー

Exception in thread "main" java.lang.StackOverflowError
  at io.ktor.openapi.JsonSchemaInferenceKt.jsonSchemaFromAnnotations(JsonSchemaInference.kt:483)
  at io.ktor.openapi.JsonSchemaInferenceKt.jsonSchemaFromAnnotations$default(JsonSchemaInference.kt:350)
  at io.ktor.openapi.KotlinxSerializerJsonSchemaInference.buildSchemaFromDescriptor$ktor_openapi_schema(JsonSchemaInference.kt:242)
  at io.ktor.openapi.JsonSchemaInferenceKt.buildJsonSchema(JsonSchemaInference.kt:301)
  at io.ktor.openapi.JsonSchemaInferenceKt.buildJsonSchema$default(JsonSchemaInference.kt:296)
  at io.ktor.openapi.KotlinxSerializerJsonSchemaInference.buildSchemaFromDescriptor$ktor_openapi_schema(JsonSchemaInference.kt:112)
  at io.ktor.openapi.JsonSchemaInferenceKt.buildJsonSchema(JsonSchemaInference.kt:301)
  at io.ktor.openapi.JsonSchemaInferenceKt.buildJsonSchema$default(JsonSchemaInference.kt:296)
  at io.ktor.openapi.KotlinxSerializerJsonSchemaInference.buildSchemaFromDescriptor$ktor_openapi_schema(JsonSchemaInference.kt:143)
  at io.ktor.openapi.KotlinxSerializerJsonSchemaInference.buildSchemaFromDescriptor$ktor_openapi_schema$default(JsonSchemaInference.kt:94)
  at io.ktor.openapi.KotlinxSerializerJsonSchemaInference.buildSchemaFromDescriptor$ktor_openapi_schema(JsonSchemaInference.kt:172)
  at io.ktor.openapi.JsonSchemaInferenceKt.buildJsonSchema(JsonSchemaInference.kt:301)
  at io.ktor.openapi.JsonSchemaInferenceKt.buildJsonSchema$default(JsonSchemaInference.kt:296)
  at io.ktor.openapi.KotlinxSerializerJsonSchemaInference.buildSchemaFromDescriptor$ktor_openapi_schema(JsonSchemaInference.kt:143)
  at io.ktor.openapi.KotlinxSerializerJsonSchemaInference.buildSchemaFromDescriptor$ktor_openapi_schema$default(JsonSchemaInference.kt:94)
  at io.ktor.openapi.KotlinxSerializerJsonSchemaInference.buildSchemaFromDescriptor$ktor_openapi_schema(JsonSchemaInference.kt:172)
  at io.ktor.openapi.JsonSchemaInferenceKt.buildJsonSchema(JsonSchemaInference.kt:301)
  at io.ktor.openapi.JsonSchemaInferenceKt.buildJsonSchema$default(JsonSchemaInference.kt:296)
  at io.ktor.openapi.KotlinxSerializerJsonSchemaInference.buildSchemaFromDescriptor$ktor_openapi_schema(JsonSchemaInference.kt:206)

io.ktor.plugin で問題を再現してみた

class KotlinxJsonSchemaInferenceTest : AbstractSchemaInferenceTest(
    KotlinxSerializerJsonSchemaInference.Default,
    "kotlinx"
) {
    @Test
    fun `sealed child recursion`() {
        val schema = inference.jsonSchema<TreeElement>()
        assertEquals("io.ktor.openapi.reflect.TreeElement", schema.title)
        assertEquals(2, schema.oneOf?.size)
    }
}

@Serializable
sealed interface TreeElement {
    @Serializable
    data class Leaf(val value: String) : TreeElement

    @Serializable
    data class Branch(val child: TreeElement) : TreeElement
}

さあ、直そう

依存関係

利用側
アプリ
build.gradle.kts
→
入口
io.ktor.plugin
OpenAPI 関連の設定を有効化
↓
↓
Ktor 本体の API
ktor-server-routing-openapi
ktor-openapi-schema に依存
→
今回の原因
ktor-openapi-schema
再帰データの schema 推論で StackOverflowError

調べてみたら報告されていた

https://youtrack.jetbrains.com/issue/KTOR-9463/OpenAPI-schema-inference-StackOverflow

もっと調べてみたら修正されていた

https://github.com/ktorio/ktor/pull/5505

早すぎる

  • 4/4 : 報告・修正済み
  • 4/10 : ‼️バグに遭遇・修正を試みる
  • 4/10 : マージ済み
  • 4月の最終週 : 3.4.3 でリリース予定

🤔 AIの活用ってどうなんだろう

おわり

Ktor 最高〜!