Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Ktor × openapi-generatorで実現する、API仕様書と実装の乖離を防ぐ仕組み

技術本部Contract One Engineering Unitの髙野です。2024年に新卒でSansanに入社しました。

Contract Oneでは、お客様が契約書の情報をさらに活用できるよう、APIの提供を開始しました。

Contract One APIの開発では、OpenAPI 3系準拠のAPI仕様書を作成しましたが、開発を進める中で「仕様書と実装の乖離」問題に直面しました。

本記事では、この問題を人によるレビューや運用で防ぐのではなく、仕組み化して防いだ事例を紹介します。

仕組み化のモチベーション

仕様書と実装が別ファイルで管理される以上、手作業での同期には限界があり、次のような問題が起こります。

  • 仕様書を更新したのに、実装は古いまま
  • 実装を更新したのに、仕様書は古いまま
  • 仕様書ではnon-nullだが、それに気づかずnullを返してしまう

人手によるレビューは属人化しやすく、このような問題の見落としは避けられません。見落としによる不具合は利用者の信頼を損ないます。また、問い合わせ対応により開発チームの時間も奪われます。誰が仕様書や実装を触っても乖離を発生させないためには、人手のレビューに頼らず、仕組みで防ぐのがいいと考えました。

仕組み化の内容

Bean ValidationとHibernate Validatorを活用し、次の3つの仕組みを導入します。

  1. 仕様書からのコード生成: OpenAPIの制約をKotlinのアノテーションとして生成する
  2. APIリクエスト時のバリデーション: 利用者側の不正入力を弾き、仕様外データの侵入を防ぐ
  3. APIレスポンス時のバリデーション: サーバー側の実装バグによる、仕様外データの出力を防ぐ

1. 仕様書からのコード生成

仕様書からコードを生成することで、人手で書く余地を減らし、仕組み上、乖離が起きづらい状態を作ります。

具体的には、openapi-generatorのKotlin系generatorを使い、リクエスト・レスポンスモデルとKtorの@Resourceクラスの2種類を生成します。ルーティングは生成せず、手書きで実装します。

生成コードにBean Validationアノテーションを付与する

単にリクエスト/レスポンスモデルを生成しただけでは、保証できるのは「フィールド名と型」に限られます。maxLength: 255minimum: 1といったOpenAPI上の制約は、Kotlinビルトインの型ではそのまま表現できません。OpenAPI上の制約を実装レベルで強制するために、Bean Validationアノテーションをコード生成に含めるようにします。

Ktorのテンプレートが含まれる公式のgenerator1はデフォルトでアノテーションを生成しません。そこで、jakarta.validationアノテーションを生成するようにテンプレートをカスタマイズしています。

> data_class.mustache例(クリックで表示)

{{#isModel}}
    @field:Valid
{{/isModel}}
{{#isArray}}
{{#items.isModel}}
    @field:Valid
{{/items.isModel}}
{{#minItems}}
    @field:Size(min = {{{minItems}}}{{#maxItems}}, max = {{{maxItems}}}{{/maxItems}})
{{/minItems}}
{{^minItems}}
{{#maxItems}}
    @field:Size(max = {{{maxItems}}})
{{/maxItems}}
{{/minItems}}
{{/isArray}}
{{^isArray}}
{{#isString}}
{{#minLength}}
    @field:Size(min = {{{minLength}}}{{#maxLength}}, max = {{{maxLength}}}{{/maxLength}})
{{/minLength}}
{{^minLength}}
{{#maxLength}}
    @field:Size(max = {{{maxLength}}})
{{/maxLength}}
{{/minLength}}
{{#pattern}}
    @field:Pattern(regexp = "{{{pattern}}}")
{{/pattern}}
{{#isEmail}}
    @field:Email
{{/isEmail}}
{{/isString}}
{{#isInteger}}
{{#minimum}}
    @field:Min({{{minimum}}})
{{/minimum}}
{{#maximum}}
    @field:Max({{{maximum}}})
{{/maximum}}
{{/isInteger}}
{{#isLong}}
{{#minimum}}
    @field:Min({{{minimum}}})
{{/minimum}}
{{#maximum}}
    @field:Max({{{maximum}}})
{{/maximum}}
{{/isLong}}
{{#isFloat}}
{{#minimum}}
    @field:DecimalMin("{{{minimum}}}")
{{/minimum}}
{{#maximum}}
    @field:DecimalMax("{{{maximum}}}")
{{/maximum}}
{{/isFloat}}
{{#isDouble}}
{{#minimum}}
    @field:DecimalMin("{{{minimum}}}")
{{/minimum}}
{{#maximum}}
    @field:DecimalMax("{{{maximum}}}")
{{/maximum}}
{{/isDouble}}
{{/isArray}}

OpenAPI上の制約とアノテーションのマッピングは次の通りです。

OpenAPI上の制約 アノテーション
minLength / maxLength @Size
pattern @Pattern
format: email @Email
minimum / maximum(整数) @Min / @Max
minimum / maximum(小数) @DecimalMin / @DecimalMax
minItems / maxItems @Size
ネストオブジェクト @Valid(再帰的に検証させる)

コード生成例

例として、次のようなOpenAPIの定義があったとします。

components:
  schemas:
    SearchRequest:
      type: object
      required:
        - limit
      properties:
        pageToken:
          type: string
        limit:
          type: integer
          minimum: 1
          maximum: 100
          default: 100

この仕様書から、SearchRequestのモデルは次のように生成されます。minimummaximum@Min/@Maxに反映されていることがわかります。

data class SearchRequest(
    val pageToken: String? = null,

    @field:Min(1)
    @field:Max(100)
    val limit: Int = 100,
)

2. APIリクエスト時のバリデーション

リクエストモデルの制約に違反するのは、利用者側が不正なリクエストを送ったタイミングです。この場合は、400 Bad Requestを返したいです。

そこで、Ktorのプラグインを定義し、Bean ValidationアノテーションをHibernate Validatorで検証するようにします。バリデーション違反があればReceiveValidationExceptionを投げ、Ktor公式のStatusPagesプラグインで共通のエラースキーマに整形して400 Bad Requestを返します。

// リクエストボディがデシリアライズされたタイミングで呼ばれるHook
private object RequestBodyTransformed : Hook<suspend (call: ApplicationCall, content: Any) -> Unit> {
    override fun install(
        pipeline: ApplicationCallPipeline,
        handler: suspend (call: ApplicationCall, content: Any) -> Unit
    ) {
        pipeline.receivePipeline.intercept(ApplicationReceivePipeline.After) {
            handler(call, subject)
        }
    }
}

// RequestBodyTransformed 時に Hibernate Validatorでリクエストボディを検証する
val ReceiveValidationPlugin = createApplicationPlugin(name = "ReceiveValidationPlugin") {
    on(RequestBodyTransformed) { _, content ->
        val violations = validator.validate(content)
        if (violations.isNotEmpty()) {
            throw ReceiveValidationException(formatViolations(violations))
        }
    }
}

これだけではリクエストボディの違反しか検知できないので、@Resourceクラスに定義されたパスパラメータ・クエリパラメータにも同じバリデーションを行えるような仕組みを用意します。

具体的には、Ktor標準のget<T> / post<T>をラップし、@Resourceクラス自体をバリデーションする validatedGet<T> / validatedPost<T>といった拡張関数を用意します。

inline fun <reified T : Any> Route.validatedGet(
    noinline body: suspend PipelineContext<Unit, ApplicationCall>.(T) -> Unit
): Route = get<T> { resource ->
    validateResource(resource)
    body(resource)
}

@PublishedApi
internal fun <T : Any> validateResource(resource: T) {
    val violations = validator.validate(resource)
    if (violations.isNotEmpty()) {
        throw ReceiveValidationException(formatViolations(violations))
    }
}

ルート定義側からは、get / postを使う代わりにvalidatedGet / validatedPostを使うだけで、勝手にバリデーションが行われます。validatedXXを使うことを強制したいので、プレゼンテーション層でio.ktor.server.resources.getなどをimportしている箇所がないかをテストで検査し、CIで弾くようにしています。

3. APIレスポンス時のバリデーション

レスポンスモデルの制約に違反するのは、サーバー側の実装バグのサインです。これも、リクエストと同じくKtorのプラグインで検知できるようにします。

レスポンスモデルの制約への違反は、本来は本番環境へ出る前に潰したいものなので、500 Internal Server ErrorにマッピングされるExceptionをthrowすることにしました。しかし、本番環境で500 Internal Server Errorを返してしまうと、本来利用者が受け取れていたはずのデータまで遮断してしまいます。

データが仕様書に違反している状態はもちろん問題ですが、そもそも機能が使えなくなるのはさらに問題なので、本番環境ではエラーログ出力に留めてアラートで検知できるようにします。

val ResponseValidationPlugin = createApplicationPlugin(
    name = "ResponseValidationPlugin",
    createConfiguration = ::ResponseValidationPluginConfig
) {
    val config = pluginConfig
    val logger = application.environment.log

    onCallRespond {
        transformBody { body ->
            val violations = validator.validate(body)
            if (violations.isNotEmpty()) {
                val message = "Response validation failed: ${formatViolations(violations)}"

                if (config.isProduction) {
                    // 本番では機能を止めないためにエラーログを出すに留める
                    logger.error(message)
                } else {
                    // 開発環境では例外をスローして問題に気づけるようにする
                    throw ResponseValidationException(message)
                }
            }
            body
        }
    }
}

仕組み化がもたらした変化

この仕組みが動き出したことで、当初抱えていた手作業での同期の限界や、人手レビューの不安は綺麗に解消されました。

一番大きな変化は、仕様書と実装の乖離が構造的に起きなくなったことです。これにより、開発時のボイラープレート的な実装(モデル、バリデーション)を意識する機会が減り、ロジックの実装に集中できます。プルリクエストのレビューで、人間が仕様書と実装の乖離をチェックする負荷がなくなったのも、チームの開発スピードを支える大きな要因になっています。

また、この仕組みは利用者体験の向上にも直結します。「仕様書通りにリクエストしているのに弾かれる」といった理不尽なエラーが起きづらくなります。仕様書に載っていないフィールドが返ってきたり、逆に載っているのに返ってこなかったりする事態をどちらも防げるため、APIとしての信頼性を高く保てます。

残課題とこれからの展望

この仕組みだけですべての課題がカバーできたわけではなく、今後の伸びしろも見えてきています。

例えば、フィールド間の相関チェックやDB問い合わせを伴うような業務ロジックに踏み込んだバリデーションは、Bean Validationだけではカバーできません。これらは現在、プレゼンテーション層やドメイン層で別途処理しており、どこまでを共通の仕組みに乗せるべきかは議論の余地があります。

また、APIの破壊的変更の検知は、現状は人手のレビューに依存しています。これについては、CIで自動検知できるツールの導入などを考えています。

まとめ

仕様書と実装の乖離は、放っておくと開発者と利用者の双方にストレスを生む原因になります。

「仕様書からのコード生成」と「APIリクエスト/レスポンス時のバリデーション」を組み合わせることで、人手のレビューに頼らず、仕様書と実装の乖離を仕組みで抑え込めるようになりました。開発者がスピード感をもって実装し、利用者がいつでも安心してAPIを叩ける環境を作る上で、このアプローチは非常に強力なセーフティネットになると実感しています。

KtorでのAPI開発に限らず、仕様書と実装の乖離に悩むどなたかの参考になれば幸いです。

Sansan技術本部ではカジュアル面談を実施しています

Sansan技術本部では中途の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。

参考


  1. openapi-generatorのKotlin系generatorにはkotlin(クライアント向け)/ kotlin-spring / kotlin-serverの3種類があります。kotlin-springはBean Validationアノテーションをデフォルトで生成しますが、Spring Boot前提なのでKtorとは噛み合いません。Type-safe routing用の@Resourceクラスはkotlin-serverのKtor templateが標準でサポートしているので、Ktorで組むならこちらが素直な選択です。ただしkotlin-serveruseBeanValidationオプションはjaxrs-spec library限定でKtor templateには効かず、kotlinクライアントにはそもそも該当オプションがありません。そのためKtor構成でBean Validationを出すには、テンプレートカスタマイズが避けられません。

© Sansan, Inc.