🫖

Kotlin + Spring Boot 3 + Springdoc-openapi で OpenAPI ドキュメントを作成する

2024/01/23に公開

概要

本記事では、Spring Boot のアノテーションから OpenAPI のドキュメントを生成するライブラリ Springdoc-openapi を Kotlin で実践します。

https://springdoc.org/

https://github.com/springdoc/springdoc-openapi

https://github.com/springdoc/springdoc-openapi-gradle-plugin

プロジェクトへ導入

本項ではプロジェクトへの導入方法を以下の手順で説明します。

  • Spring Initializr でプロジェクトを作成
  • build.gradle.kts の設定

Spring Initializr でプロジェクトを作成

まずは、Spring Initializr で Spring Boot プロジェクトを作成します。

本記事では以下の内容で作成しました。

項目 内容 補足
Project Gradle Kotlin
Language Kotlin
Spring Boot 3.2.2 時期によって選択できるバージョンが変わります。3 系のスナップショットでない方を選んでください。
Artifact springdoc-openapi-genapi-doc
Name springdoc-openapi-genapi-doc Artifact を入力したら自動で更新されます
Description Demo project for Spring Boot デフォルトのままです
Java 17
Dependencies Spring Web

Spring Initilizar
Spring Initilizar 入力後

以下に入力済みの Spring Initializr のリンクを記載します。

https://start.spring.io/#!type=gradle-project-kotlin&language=kotlin&platformVersion=3.2.2&packaging=jar&jvmVersion=17&groupId=com.example&artifactId=springdoc-openapi-genapi-doc&name=springdoc-openapi-genapi-doc&description=Demo project for Spring Boot&packageName=com.example.springdoc-openapi-genapi-doc&dependencies=web

作成したアプリケーションを適当なディレクトリに写して unzip してください。

build.gradle.kts の設定

プロジェクトを開いて、インデックスの作成を終えたら、build.gradle.kts を編集します。
具体的には、build.gradle.kts に SpringDoc の plugin と dependencies を記述します。
下記の例では、validation を有効化するため、Spring Boot Starter Validation も記載しています。

build.gradle.kts
plugins {
 	// 略
	/**
	 * springdoc
	 *
	 * URL
	 * - https://springdoc.org/
	 * Main 用途
	 * - OpenAPI 仕様に基づいたドキュメントを生成する
	 * Sub 用途
	 * - なし
	 * 概要
	 * コードから OpenAPI 仕様に基づいたドキュメントの生成ライブラリ
	 */
	id("org.springdoc.openapi-gradle-plugin") version "1.8.0"
}

dependencies {
 	// 略
	/**
	 * Spring Boot Starter Validation
	 *
	 * MavenCentral
	 * - https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-validation
	 * Main用途
	 * - コントローラーのバリデーションのために利用する
	 * Sub用途
	 * - 無し
	 * 概要
	 * - Validation を実装した際に、本ライブラリがなければ、バリデーションが動作しない
	 */
	implementation("org.springframework.boot:spring-boot-starter-validation")
}

また、dependencies に以下を記入します。この内容は SpringDoc を CLI から生成するために必要です。

build.gradle.kts
dependencies {
 	// 略
	/**
	 * springdoc の gradle 拡張
	 *
	 * 概要
	 * - CLI から springdoc を利用して OpenAPI を 生成する
	 */
	implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0")
}

最後に、openapi.yaml を生成するコマンドを記述します。
上から順番に以下の内容になっています。

build.gradle.kts
openApi {
	apiDocsUrl.set("http://localhost:8080/v3/api-docs.yaml")
	outputDir.set(project.layout.buildDirectory.dir("springdoc"))
	outputFileName.set("openapi.yaml")
}

以上の設定から以下のコマンドで build/springdoc/openapi.yaml に生成できます。
しかし、すでにファイルが存在するか、キャッシュが存在していると、上書きされません。
そのため筆者は、rm build/springdoc/openapi.yaml で削除して逐次実行しています。
特筆しなければ毎回削除してから実行していると考えてください。

./gradlew generateOpenApiDocs

また、SpringBoot アプリケーションを起動した状態だと、http://localhost:8080/swagger-ui/index.html に Swagger UI で表示できます。

アノテーションの記述方法

本項では、SpringBoot のサンプルアプリケーションで具体的なアノテーションの記述方法を記載します。
DB とは接続しませんが、「新規記事を作成するエンドポイントを持つ Web API」という設定のコードを作成します。
完成したサンプルコードは以下に記載しています。

https://github.com/Msksgm/springdoc-openapi-gen-apidoc

アノテーションの記述方法は以下の swagger-api の GitHub に記載されています。
本記事では基本的な部分の説明に注力し、OpenAPIDefinition、Operation、Media アノテーションのみ取り扱います。

https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Annotations

OpenAPIDefinition アノテーション

OpenAPIDefinition アノテーションは OpenAPI の定義について記述するアノテーションです。
具体的には以下のようなアノテーションが含まれています。

  • @OpenAPIDefinition
  • @Info
  • @License

下記のように、OpenAPIDefinition によって、API 名、ライセンス、サーバ情報などを取得できます。

以下のように、@SpringBootApplication を付与するクラスに@OpenAPIDefinition アノテーションを記述します。
今回の例では Server アノテーションが記述されていますが、Server アノテーション自体は後述する Operation アノテーションに含まれています。

src/main/kotlin/com/example/springdocopenapigenapidoc/SpringdocOpenapiGenApidocApplication.kt
/**
 * SpringdocOpenapiGenApidocApplication
 */
@SpringBootApplication
@OpenAPIDefinition(
    info = Info(
        title = "API Title",
        version = "0.0",
        description = "My API",
        license = License(name = "Apache 2.0", url = "http://sample"),
        contact = Contact(url = "http://sample.com", name = "sample", email = "sample@sample.com")
    ),
    servers = [
        Server(
            description = "ローカルサーバーの説明です",
            url = "http://localhost:8080",
        ),
        Server(
            description = "事前環境サーバーの説明です",
            url = "http://stg:8080",
        ),
        Server(
            description = "本番環境サーバーの説明です",
            url = "http://prod:8080",
        ),
    ],
)
class SpringdocOpenapiGenApidocApplication

Operation アノテーション・Media アノテーション

続いて、Operation アノテーションMeida アノテーションについて説明します。
リクエストボディ、レスポンスボディ、コントローラなどに記述し、メソッドごとの API 定義・サンプルや OpenAPI のタグの設定をします。

Operation アノテーションには以下のアノテーションなどが含まれます。
メソッドの定義やリクエストパラメータ、レスポンスの定義などを設定します。

Media アノテーションには以下のアノテーションなどが含まれます。
@Schema アノテーションはフィールドの説明や必須かどうかを説明したり、@ApiResponse アノテーションと組み合わせてレスポンスのスキーマ定義やサンプルを表示します。

それぞれ、リクエストボディ、レスポンスボディ、コントローラの例を記述します。

リクエストボディ

次の例は、新規記事作成リクエストボディの data class です。
@Schema アノテーションが必須かどうかや説明を記述しています。

src/main/kotlin/com/example/springdocopenapigenapidoc/model/NewArticleRequest.kt
/**
 * 新規記事作成のリクエストモデル
 *
 * @property article
 */
data class NewArticleRequest(
    @field:Valid
    @field:NotNull
    @Schema(required = true, description = "記事を作成する時のリクエスト")
    @field:JsonProperty("article", required = true) val article: NewArticle? = null
)

src/main/kotlin/com/example/springdocopenapigenapidoc/model/NewArticle.kt
/**
 * NewArticle
 *
 * 新規記事のモデル
 *
 * @property title タイトル
 * @property description 説明
 * @property body 本文
 */
data class NewArticle(
    @field:Size(max = 32)
    @field:NotBlank
    @Schema(example = "new-article-title", required = true, description = "新規記事のタイトル")
    @field:JsonProperty("title", required = true) val title: String? = null,

    @field:Size(max = 1024)
    @field:NotBlank
    @Schema(example = "new-article-description", required = true, description = "新規記事の説明")
    @field:JsonProperty("description", required = true) val description: String? = null,

    @field:Size(max = 2048)
    @field:NotBlank
    @Schema(example = "new-article-body", required = true, description = "新規記事の説明")
    @field:JsonProperty("body", required = true) val body: String? = null,
)

レスポンスボディ

以下は、新規記事作成のレスポンスボディです。
レスポンスボディもリクエストボディと基本的には同様でフィールドにスキーマを定義します。

src/main/kotlin/com/example/springdocopenapigenapidoc/model/SingleArticleResponse.kt
/**
 * 単一記事のレスポンスモデル
 *
 * @property article
 */
data class SingleArticleResponse(
    @field:Valid
    @Schema(required = true, description = "新規記事")
    @field:JsonProperty("article", required = true) val article: Article
)

src/main/kotlin/com/example/springdocopenapigenapidoc/model/Article.kt
/**
 * Article
 *
 * 記事のモデル
 *
 * @property slug スラッグ
 * @property title タイトル
 * @property description 説明
 * @property body 本文
 */
data class Article(
    @get:Size(max = 32)
    @Schema(example = "article-slug", required = true, description = "")
    @field:JsonProperty("slug", required = true) val slug: String,

    @get:Size(max = 64)
    @Schema(example = "article-title", required = true, description = "")
    @field:JsonProperty("title", required = true) val title: String,

    @get:Size(max = 1024)
    @Schema(example = "article-description", required = true, description = "")
    @field:JsonProperty("description", required = true) val description: String,

    @get:Size(max = 4096)
    @Schema(example = "article-body", required = true, description = "")
    @field:JsonProperty("body", required = true) val body: String
)

以下はエラーレスポンスを返すときの data class です。

src/main/kotlin/com/example/springdocopenapigenapidoc/model/GenericErrorModel.kt
/**
 * GenericErrorModel
 *
 * エラーレスポンスを返す時に利用するモデル
 *
 * @property errors
 */
data class GenericErrorModel(
    @field:Valid
    @Schema(required = true, description = "")
    @field:JsonProperty("errors", required = true) val errors: GenericErrorModelErrors
)

src/main/kotlin/com/example/springdocopenapigenapidoc/model/GenericErrorModelErrors.kt
/**
 * GenericErrorModelErrors
 *
 * エラーレスポンスの詳細を記述するモデル
 *
 * @property body エラーの詳細
 */
data class GenericErrorModelErrors(
    @Schema(required = true, description = "")
    @field:JsonProperty("body", required = true) val body: List<String>
)

コントローラ

最後にコントローラについて説明します。
基本的な SpringBoot のアノテーションに加えて、SpringDoc のアノテーションも記述しています。
具体的には Operation アノテーション、Tag アノテーション、Content アノテーション、Schema アノテーションなどを利用しています。
Tag アノテーションで openapi.yaml の生成時にタグによるグルーピングをします。
Operation アノテーションで、メソッドごとの説明を記述したり、レスポンスのスキーマは具体例を記述できます。

/**
 * SpringDoc のサンプルアプリ用 ArticleController
 */
@RestController
@Tag(name = "articles", description = "記事に関するエンドポイント")
@Validated
class ArticleController {
    /**
     * 記事作成エンドポイント
     *
     * @param newArticleRequest
     * @return
     */
    @Operation(
        summary = "記事作成",
        operationId = "createArticle",
        description = "記事を作成します。",
        responses = [
            ApiResponse(
                responseCode = "201",
                description = "OK",
                content = [
                    Content(
                        schema = Schema(implementation = SingleArticleResponse::class),
                        examples = [
                            ExampleObject(
                                name = "OK",
                                value = """
                                {
                                    "article": {
                                        "slug": "new-slug",
                                        "title": "new-title",
                                        "body": "new-body",
                                        "description": "new-description"
                                    }
                                }
                            """
                            )
                        ]
                    )
                ]
            ),
            ApiResponse(
                responseCode = "400",
                description = "Validation Error",
                content = [
                    Content(
                        schema = Schema(implementation = GenericErrorModel::class),
                        examples = [
                            ExampleObject(
                                name = "ValidationError",
                                value = """
                                    {
                                        "errors": {
                                            "body": [
                                                "article.titleは0文字以上32文字以下です",
                                                "article.bodyは必須です"
                                            ]
                                        }
                                    }
                                """
                            )
                        ]
                    )
                ]
            ),
            ApiResponse(
                responseCode = "409",
                description = "Already Exists",
                content = [
                    Content(
                        schema = Schema(implementation = GenericErrorModel::class),
                        examples = [
                            ExampleObject(
                                name = "AlreadyExists",
                                value = """
                                    {
                                        "errors": {
                                            "body": [
                                                "記事はすでに存在しています"
                                            ]
                                        }
                                    }
                                """
                            )
                        ]
                    )
                ]
            )
        ]
    )
    @PostMapping(
        value = ["/articles"],
        produces = [MediaType.APPLICATION_JSON_VALUE],
        consumes = [MediaType.APPLICATION_JSON_VALUE],
    )
    fun createArticle(
        @Parameter(
            description = "新規記事作成",
            required = true
        )
        // Sample プロジェクトのため、Suppress を利用。本番環境では削除する
        @Valid @RequestBody @Suppress("UnusedParameter") newArticleRequest: NewArticleRequest,
    ): ResponseEntity<SingleArticleResponse> {
        return ResponseEntity(
            SingleArticleResponse(
                article = Article(
                    slug = "new-slug",
                    title = "new-title",
                    body = "new-body",
                    description = "new-description"
                )
            ),
            HttpStatus.OK
        )
    }

    /**
     * 記事作成エンドポイントに発生した例外を表現するクラス
     *
     * @property error エラー
     */
    data class CreateArticleErrorException(val error: Error) : Throwable()

    /**
     * 記事作成エンドポイントの例外をハンドリングする関数
     *
     * @param e
     * @return
     */
    @ExceptionHandler(CreateArticleErrorException::class)
    fun onCreateArticleErrorException(e: CreateArticleErrorException): ResponseEntity<GenericErrorModel> {
        // Sample プロジェクトのため、AlreadyExists 以外は BadRequest としてエラーレスポンスを返している
        if (e.error.toString() == "AlreadyExists") {
            return ResponseEntity(
                GenericErrorModel(
                    errors = GenericErrorModelErrors(
                        body = listOf(e.error.toString())
                    )
                ),
                HttpStatus.CONFLICT
            )
        }
        return ResponseEntity(
            GenericErrorModel(
                errors = GenericErrorModelErrors(
                    body = listOf(e.error.toString())
                )
            ),
            HttpStatus.BAD_REQUEST
        )
    }
}

生成結果

以上の内容を踏まえて、openapi.yaml を生成します。

SpringBoot アプリケーションを起動して、http://localhost:8080/swagger-ui/index.html を参照すると、以下の画像のように表示されます。

Swagger UI
Swagger UI

CLI から yaml ファイルを生成したい場合は以下のコマンドを実行します。

openapi.yaml を生成
./gradlew generateOpenApiDocs

build/springdoc/openapi.yaml にファイルが生成され、結果は下記の折りたたみの通りです。

生成結果。
build/springdoc/openapi.yaml
openapi: 3.0.1
info:
  title: API Title
  description: My API
  contact:
    name: sample
    url: http://sample.com
    email: sample@sample.com
  license:
    name: Apache 2.0
    url: http://sample
  version: "0.0"
servers:
  - url: http://localhost:8080
    description: ローカルサーバーの説明です
  - url: http://stg:8080
    description: 事前環境サーバーの説明です
  - url: http://prod:8080
    description: 本番環境サーバーの説明です
tags:
  - name: articles
    description: 記事に関するエンドポイント
paths:
  /articles:
    post:
      tags:
        - articles
      summary: 記事作成
      description: 記事を作成します。
      operationId: createArticle
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NewArticleRequest"
        required: true
      responses:
        "201":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SingleArticleResponse"
              examples:
                OK:
                  description: OK
                  value:
                    article:
                      slug: new-slug
                      title: new-title
                      body: new-body
                      description: new-description
        "400":
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenericErrorModel"
              examples:
                ValidationError:
                  description: ValidationError
                  value:
                    errors:
                      body:
                        - article.titleは0文字以上32文字以下です
                        - article.bodyは必須です
        "409":
          description: Already Exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenericErrorModel"
              examples:
                AlreadyExists:
                  description: AlreadyExists
                  value:
                    errors:
                      body:
                        - 記事はすでに存在しています
components:
  schemas:
    Article:
      required:
        - body
        - description
        - slug
        - title
      type: object
      properties:
        slug:
          maxLength: 32
          minLength: 0
          type: string
          example: article-slug
        title:
          maxLength: 64
          minLength: 0
          type: string
          example: article-title
        description:
          maxLength: 1024
          minLength: 0
          type: string
          example: article-description
        body:
          maxLength: 4096
          minLength: 0
          type: string
          example: article-body
      description: 新規記事
    SingleArticleResponse:
      required:
        - article
      type: object
      properties:
        article:
          $ref: "#/components/schemas/Article"
    GenericErrorModel:
      required:
        - errors
      type: object
      properties:
        errors:
          $ref: "#/components/schemas/GenericErrorModelErrors"
    GenericErrorModelErrors:
      required:
        - body
      type: object
      properties:
        body:
          type: array
          items:
            type: string
    NewArticle:
      required:
        - body
        - description
        - title
      type: object
      properties:
        title:
          maxLength: 32
          minLength: 0
          type: string
          description: 新規記事のタイトル
          example: new-article-title
        description:
          maxLength: 1024
          minLength: 0
          type: string
          description: 新規記事の説明
          example: new-article-description
        body:
          maxLength: 2048
          minLength: 0
          type: string
          description: 新規記事の説明
          example: new-article-body
      description: 記事を作成する時のリクエスト
    NewArticleRequest:
      required:
        - article
      type: object
      properties:
        article:
          $ref: "#/components/schemas/NewArticle"
      description: 新規記事作成

参考

https://zenn.dev/wtetsu/articles/026788e2571175

https://qiita.com/takkkun/items/ae03832b436bb1760ec4

Discussion