Creating Spring REST Docs with Kotlin

Overview

Spring REST Docs is used to document APIs for RESTful services. API documentation is generated from test code.

Here, we will create a simple Spring REST Docs project with Kotlin.

Create the Project

Create a Spring Boot starter project with the following curl command.

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 -

Running the command above adds the Spring Web and Spring REST Docs dependencies.

Check the Generated Project

Project File Structure

If the project was generated correctly, its file structure is as follows.

.
├── 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 Script

/build.gradle.kts

// ... omitted ...

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")
}

// ... omitted ...

You can see that the Spring REST Docs library (spring-restdocs-mockmvc) is included as a test dependency.

REST Docs Configuration

Fix the Initial Build Script Error

When you first build the project, an error like the following may occur.

> 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

The error occurs because the configuration file was not generated correctly. In the code below, the task uses a variable named snippetsDir, but snippetsDir is not declared properly.

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

... omitted ...

tasks.test {
    outputs.dir(snippetsDir)
}

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

Change it as follows.

//extra["snippetsDir"] = file("build/generated-snippets")
val snippetsDir by extra { file("build/generated-snippets") } // variable change

... omitted ...

tasks.test {
    outputs.dir(snippetsDir)
}

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

Run the build again, and the error should disappear.

Add REST Docs Configuration to the Build Script

... omitted ....

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
}

... omitted ....

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

    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"))
}

The script above is explained below.

  1. Add the asciidoctor dependency.
    • If this dependency is not added, the documentation may not be generated correctly.
  2. Delete the HTML generated by the asciidoctor task.
    • This removes the generated HTML documentation.
  3. Register the copyHTML task.
    • This task depends on tasks.asciidoctor.
    • As its task work, it copies the HTML documents in the build context path (build/asciidoc/html5) to src/main/resources/static/docs. This makes it possible to serve the generated specification documents when the actual application runs.
  4. Run copyHTML during the build.
    • The dependsOn function adds a dependency on the copyHTML task.
    • When the build task runs, copyHTML runs first, and then the build starts.
  5. Generate and copy the documentation when building an executable jar.
    • When the bootJar task runs, the asciidoctor and copyHTML tasks run first.

Implement the REST API

Let’s create a simple REST API to generate API documentation.

Create the Entity Class

/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
)

Create the Controller Class

/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) }
    }
}

Create the Test Code

Now write the test code for the API documentation.

/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")
                    )
                )
            )
    }
}

The code above is explained below.

  1. This is simple test code for testing.
  2. This code runs the test and generates documentation.

After running the test code, check whether the snippet generation folder (build/generated-snippets) was created correctly.

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

Check the Gradle Version If an Error Occurs During Testing

After adding the settings to the build script above, a build may fail with an error like the following.

... omitted ...

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.

... omitted ...

This is an issue that occurs in Gradle 7. There is a workaround, but it is complicated and not recommended. It may be better to consider upgrading the version after it is officially resolved later.

Change the Gradle version as follows.

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

Write AsciiDoc

AsciiDoc documents use the .adoc extension and follow the AsciiDoc syntax.

/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[]

Check the asciidoctor Dependency

If you run the build and see an error like the following:

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

and the HTML document is not generated correctly,

Spring Rest Docs snippet error

check whether the spring-restdocs-asciidoctor dependency is configured correctly.

dependencies {
    ... omitted ...

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

If You Use Git

The generated HTML document does not need to be managed in version control, so remove it from managed files.

Add the excluded folder to the .gitignore file as follows.

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

Final Check

If the documentation is finally generated correctly, you can see a document like the following.

Spring Rest Docs document

Closing

All source code above is available on GitHub.

References