KotlinでSpring REST Docsを作る

概要

Spring REST Docsは、RESTfulサービスのAPIドキュメント作成に使用されます。APIドキュメントはテストコードに基づいて作成されます。

ここでは、Kotlin言語を利用して簡単なSpring REST Docsプロジェクトを作成します。

プロジェクト作成

次のようにcurlコマンドを使用して、Spring Bootの初期プロジェクトを作成します。

curl https://start.spring.io/starter.tgz \
-d bootVersion=2.5.6 \
-d dependencies=web,restdocs \
-d baseDir=spring-rest-docs \
-d groupId=com.devkuma \
-d artifactId=spring-rest-docs \
-d packageName=com.devkuma.rest.docs \
-d applicationName=RestDocsApplication \
-d packaging=jar \
-d language=kotlin \
-d javaVersion=11 \
-d type=gradle-project | tar -xzvf -

上記のコマンドを実行すると、Spring WebとSpring REST Docsの依存関係が追加されます。

生成されたプロジェクトの確認

プロジェクトファイル構造

正しく生成されていれば、プロジェクトのファイル構造は次のようになります。

.
├── HELP.md
├── build.gradle.kts
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
    ├── main
    │   ├── kotlin
    │   │   └── com
    │   │       └── devkuma
    │   │           └── rest
    │   │               └── docs
    │   │                   └── RestDocsApplication.kt
    │   └── resources
    │       ├── application.properties
    │       ├── static
    │       └── templates
    └── test
        └── kotlin
            └── com
                └── devkuma
                    └── rest
                        └── docs
                            └── RestDocsApplicationTests.kt

ビルドスクリプト

/build.gradle.kts

// ... 省略 ...

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc")
}

// ... 省略 ...

依存ライブラリにSpring REST Docsライブラリ(spring-restdocs-mockmvc)がテスト用として含まれていることがわかります。

REST Docs設定

初期ビルドスクリプトエラーの修正

最初にプロジェクトをビルドすると、次のようなエラーが発生することがあります。

> Configure project :
e: /Users/kimkc/develop/tutorial/spring-tutorial/spring-rest-docs/build.gradle.kts:42:14: Unresolved reference: snippetsDir
e: /Users/kimkc/develop/tutorial/spring-tutorial/spring-rest-docs/build.gradle.kts:46:13: Unresolved reference: snippetsDir
e: /Users/kimkc/develop/tutorial/spring-tutorial/spring-rest-docs/build.gradle.kts:47:12: Unresolved reference. None of the following candidates is applicable because of receiver type mismatch: 
public val NamedDomainObjectContainer<KotlinSourceSet>.test: NamedDomainObjectProvider<KotlinSourceSet> defined in org.gradle.kotlin.dsl
public val SourceSetContainer.test: NamedDomainObjectProvider<SourceSet> defined in org.gradle.kotlin.dsl
public val TaskContainer.test: TaskProvider<Test> defined in org.gradle.kotlin.dsl

エラーが発生する原因は、設定ファイルが正しく生成されていないためです。次のコードを見ると、taskではsnippetsDir変数として使用していますが、snippetsDirが正しく宣言されていません。

extra["snippetsDir"] = file("build/generated-snippets")

... 省略 ...

tasks.test {
    outputs.dir(snippetsDir)
}

tasks.asciidoctor {
    inputs.dir(snippetsDir)
    dependsOn(test)
}

次のように変更します。

//extra["snippetsDir"] = file("build/generated-snippets")
val snippetsDir by extra { file("build/generated-snippets") } // 変数変更

... 省略 ...

tasks.test {
    outputs.dir(snippetsDir)
}

tasks.asciidoctor {
    inputs.dir(snippetsDir)
    //dependsOn(test)
    dependsOn(tasks.test) // 変更
}

再度実行すると、エラーはなくなるはずです。

ビルドスクリプトにREST Docs関連設定を追加

... 省略 ....

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc")

    asciidoctor("org.springframework.restdocs:spring-restdocs-asciidoctor") // 1
}

... 省略 ....

tasks.asciidoctor {
    inputs.dir(snippetsDir)
    //dependsOn(test)
    dependsOn(tasks.test) // 変更

    doFirst { // 2
        delete {
            file("src/main/resources/static/docs")
        }
    }
}

tasks.register("copyHTML", Copy::class) { // 3
    dependsOn(tasks.asciidoctor)
    from(file("build/asciidoc/html5"))
    into(file("src/main/resources/static/docs"))
}

tasks.build { // 4
    dependsOn(tasks.getByName("copyHTML"))
}

tasks.bootJar { // 5
    dependsOn(tasks.asciidoctor)
    dependsOn(tasks.getByName("copyHTML"))
}

上記スクリプトの説明は次のとおりです。

  1. asciidoctor依存関係設定を追加します。
    • この依存関係を追加しないと、ドキュメントが正しく生成されない場合があります。
  2. asciidoctorタスクで生成されたHTMLを削除します。
    • 生成されたHTMLドキュメントを削除します。
  3. copyHTMLタスクを登録します。
    • このタスクはtasks.asciidoctorタスクに依存しています。
    • タスクの処理として、ビルドコンテキストパス(build/asciidoc/html5)にあるHTMLドキュメントをsrc/main/resources/static/docsへコピーします。こうすることで、実際のアプリケーション実行時に生成された仕様書を提供できます。
  4. ビルド実行時にcopyHTMLを実行します。
    • dependsOn関数でcopyHTMLタスクへの依存を追加しています。
    • ビルドタスクが実行されると、まずcopyHTMLが実行され、その後ビルドが開始されます。
  5. 実行可能jarをビルドするときにドキュメント生成とコピーを実行します。
    • bootJarタスクが実行されると、asciidoctorcopyHTMLタスクが先に実行されます。

REST API実装

APIドキュメントを作成するために、簡単なREST APIを作ってみます。

エンティティクラス作成

/src/main/kotlin/com/devkuma/rest/docs/contrentityoller/Product.kt

package com.devkuma.rest.docs.entity

data class Product(
    val code: String,
    val name: String,
    val price: Int
)

コントローラークラス作成

/src/main/kotlin/com/devkuma/rest/docs/controller/ProductController.kt

package com.devkuma.rest.docs.controller

import com.devkuma.rest.docs.entity.Product
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/v1/products")
class ProductController {

    private var products: List<Product> = listOf(
        Product("1", "Keyboard", 2000),
        Product("2", "Monitor", 3000),
        Product("3", "Mouse", 1000),
    )

    @GetMapping("")
    fun getProducts(): List<Product> {
        return products
    }

    @GetMapping("/{code}")
    fun getProduct(@PathVariable code: String): Product {
        return products.first { p -> p.code.equals(code, true) }
    }
}

テストコード作成

次に、APIドキュメントのためのテストコードを書いてみます。

/src/test/kotlin/com/devkuma/rest/docs/controller/ProductControllerTests.kt

package com.devkuma.rest.docs.controller

import org.hamcrest.CoreMatchers.containsString
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders
import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath
import org.springframework.restdocs.payload.PayloadDocumentation.responseFields
import org.springframework.restdocs.request.RequestDocumentation.parameterWithName
import org.springframework.restdocs.request.RequestDocumentation.pathParameters
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status

@WebMvcTest(ProductController::class)
@AutoConfigureRestDocs
class ProductControllerTests {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Test
    fun `GET v1-product 200 ok`() { // 1

        // When
        val result = mockMvc.perform(get("/v1/products/2"))

        // Then
        result.andExpect(status().isOk)
            .andExpect(MockMvcResultMatchers.content().string(containsString("Monitor")))
    }

    @Test
    fun `GET v1-product 200 ok document`() { // 2
        // When
        val result = mockMvc.perform(RestDocumentationRequestBuilders.get("/v1/products/{code}", 2))

        // Then
        result.andExpect(status().isOk)
            .andExpect(MockMvcResultMatchers.content().string(containsString("Monitor")))
            .andDo(
                document(
                    "product/get-product-by-id",
                    pathParameters(
                        parameterWithName("code").description("Product Unique Identifier")
                    ), responseFields(
                        fieldWithPath("code").description("Product Unique Identifier"),
                        fieldWithPath("name").description("Name of the product"),
                        fieldWithPath("price").description("Product Price")
                    )
                )
            )
    }
}

上記コードの説明は次のとおりです。

  1. 単純にテストのためのテストコードです。
  2. テストを実行し、ドキュメントを生成するコードです。

テストコードを実行した後、スニペット生成フォルダ(build/generated-snippets)が正しく生成されたか確認してみましょう。

build/generated-snippets
└── product
    └── get-product-by-id
        ├── curl-request.adoc
        ├── http-request.adoc
        ├── http-response.adoc
        ├── httpie-request.adoc
        ├── path-parameters.adoc
        ├── request-body.adoc
        ├── response-body.adoc
        └── response-fields.adoc

テスト実行時にエラーが発生した場合はGradleバージョンを確認

上記のビルドスクリプトに設定を追加した後、ビルドを実行すると次のようなエラーが発生する場合があります。

... 省略 ...

FAILURE: Build failed with an exception.

* What went wrong:
Some problems were found with the configuration of task ':asciidoctor' (type 'AsciidoctorTask').
  - In plugin 'org.asciidoctor.convert' type 'org.asciidoctor.gradle.AsciidoctorTask' method 'asGemPath()' should not be annotated with: @Optional, @InputDirectory.

... 省略 ...

これはGradle 7バージョンで発生する問題です。回避方法はありますが複雑なのでおすすめしません。今後正式に解決された後で、バージョンアップを検討するのがよさそうです。

Gradleバージョンを変更する方法は次のとおりです。

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
#distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-6.9.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

AsciiDoc作成

AsciiDoc文書は.adocという拡張子を使用し、AsciiDoc文法に従います。

/src/docs/asciidoc/index.adoc

=== Get Product By Id
Obtains a specific products by its unique identifier.

==== Sample Request
include::{snippets}/product/get-product-by-id/http-request.adoc[]

==== Sample Response
include::{snippets}/product/get-product-by-id/http-response.adoc[]

==== CURL sample
include::{snippets}/product/get-product-by-id/curl-request.adoc[]

==== Path Parameters
include::{snippets}/product/get-product-by-id/path-parameters.adoc[]

==== Response Fields
include::{snippets}/product/get-product-by-id/response-fields.adoc[]

asciidoctor依存関係の確認

もしビルドを実行して次のようなエラーが発生し、

> Task :asciidoctor
asciidoctor: WARNING: dropping line containing reference to missing attribute: snippets

HTMLドキュメントが正しく生成されない場合は、

Spring Rest Docsスニペットエラー

依存関係にspring-restdocs-asciidoctor設定が正しく追加されているか確認してみましょう。

dependencies {
    ... 省略 ...

    asciidoctor("org.springframework.restdocs:spring-restdocs-asciidoctor") // 1
}

Gitを使用する場合

生成されたHTMLドキュメントはバージョン管理で管理する必要がないため、管理対象ファイルから除外しましょう。

.gitignoreファイルに次のように除外フォルダを追加すればよいです。

**/src/main/resources/static/docs/

最終確認

最終的にドキュメントが正しく生成されていれば、次のようなドキュメントを確認できます。

Spring Rest Docs文書

まとめ

上記のすべてのソースコードはGitHubで確認できます。

参考