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=kotlin-spring-rest-docs \
-d groupId=com.devkuma \
-d artifactId=kotlin-spring-rest-docs \
-d packageName=com.devkuma.rest.docs \
-d applicationName=RestDocsApplication \
-d packaging=jar \
-d language=kotlin \
-d jvmVersion=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")
}

// ... 생략 ...

의존성 라이브러리에 스프링 REST Docs 라이브러리(spring-restdocs-mockmvc)가 test용으로 포함된 것을 볼 수 있다.

REST Docs 설정

초기 빌드 스크립트 에러 수정

처음 프로젝트를 빌드를 하면 아래와 같이 에러가 발생할 것이다.

> Configure project :
e: /Users/kimkc/develop/tutorial/kotlin-tutorial/kotlin-spring-rest-docs/build.gradle.kts:42:14: Unresolved reference: snippetsDir
e: /Users/kimkc/develop/tutorial/kotlin-tutorial/kotlin-spring-rest-docs/build.gradle.kts:46:13: Unresolved reference: snippetsDir
e: /Users/kimkc/develop/tutorial/kotlin-tutorial/kotlin-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 테스크가 실행되면, asciidoctor, copyHTML 테스크가 먼저 수행된다.

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에서 확인해 볼 수 있다.

참조