Rest docs

5 minute read

힘들었다.

저는 요즘 kotlin과 spring boot를 이용한 서버 개발을 하고 있습니다. 먼저 개발하고 계시던 분이 Rest docs를 이용한 API 문서 자동화가 필요하다 말씀하셔서 작업을 시작했습니다. 우선, 어찌 저찌 얼레 벌레 하긴 했는데.. 중간 과정이 깔끔하지 않았습니다. 일단 레퍼런스가 적어서 삽질을 많이 했습니다. 일부 커스터마이징 작업이 필요한 부분에서는 겨우 찾은 레퍼런스에서도 설명이 누락된 부분이 많았습니다. 코틀린을 쓰느냐, 자바를 쓰느냐에 따라 달라지는 설명도 대부분 암묵적으로 자바를 쓴다고 전제를 깔더군요(ㅂㄷㅂㄷ). 이런 점들이 상당히 아쉬웠기에 나 하나로 끝났으면 좋겠다는 마음으로 차근 차근 코틀린 기반으로 정리해보려고 합니다.

Rest docs

Rest docs란 Spring으로 개발한 REST API를 자동으로 문서화 해주는 도구입니다. 사용해본 적은 없지만 Swagger라는 유명한 문서화 자동 툴도 존재하는데 실제 운영코드에 문서화를 위한 코드나 어노테이션을 작성해야 API 문서를 생성해주는 걸로 알고 있습니다. 반대로, Rest docs는 테스트 코드에서 시작합니다. 심지어 테스트 코드가 성공해야 문서로 만들 수 있습니다. 개인적으론 실제 운영 코드에 작업하지 않아도 된다는 점과 테스트를 통과해야만 문서로 만들어 준다는 점이 유지보수성, 심리적 안정감 측면에서 더 나아 보입니다.

ascii doc

Rest docs는 기본적으로 ascii doc을 기반으로 만들어집니다. markdown과 연동할 수 있다고 하는데, 이 과정 자체가 별로 순탄해 보이지 않고 공식문서와 대부분의 레퍼런스들이 ascii doc 기반으로 설명하고 있기에 가급적이면 디폴트로 사용하는 것을 추천합니다. 실제로 markdown을 잘 쓰시는 분이라면 ascii doc도 제법 빠르게 습득하실 수 있습니다.(저도 했으니까요)

build.gradle.kts 기초 작업

Rest docs를 사용하기 위해선 기초 작업들이 선행돼야 합니다. 우선 build.gradle.kts를 수정합니다. 아, 그리고 해당 포스팅에선 mockmvc를 사용합니다.

plugins {
    . . . 
    
    id("org.asciidoctor.jvm.convert") version "3.3.2" // (1)
    
    . . .
}

dependencies {
    . . . 
    
    testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc:2.0.5.RELEASE") // (2)
    
    . . .
}

val snippetsDir = file("build/generated-snippets") // (3)

tasks.withType<Test> {
    useJUnitPlatform()
    outputs.dir(snippetsDir)
}  // (4)

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

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

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

tasks.build {
    dependsOn(tasks.asciidoctor)
}  // (8)

(1) 테스트 성공시 생성되는 {file-name}.adoc 들을 취합해 html로 변환해주는 플러그인입니다. 등록해주세요.

  • gradle version 7 부턴 org.asciidoctor.jvm.convert를 사용하며 그 아래 버전은 org.asciidoctor.convert를 씁니다.

(2) 해당 포스트에서는 MockMVC를 사용합니다. MockMVC와 Rest docs가 연동되도록 추가해줍니다.

(3) {file-name}.adoc 파일들이 생성될 snippet 경로를 지정합니다.

(4) Test를 실행할 때 {file-name}.adoc들을 스니펫 경로에 생성하게끔 지정합니다.

(5) asciidoctor가 adoc파일을 어느 경로에 생성할지 지정합니다.

(6) 웹에서 API문서를 조회할 수 있도록 빌드가 끝나면 생성된 html 파일들을 src/main/resources/static/docs에 복사합니다. ( {file-name}.adoc 파일들이 취합되면 build/docs/asciidoc 경로에 html 파일 생성)

(7) 실행 가능한 jar를 빌드할 때 asciidoctor와 copyHTML task를 같이 실행하도록 추가해줍니다.

(8) 빌드할 때 asciidoctor task가 실행되도록 추가해줍니다.

테스트 작성

앞서 말했지만 Rest docs는 테스트를 성공해야만 해당 API의 파편 {file-name}.adoc 파일들을 생성해줍니다. 빠르게 테스트를 작성해봅니다.

import org.springframework.restdocs.headers.HeaderDocumentation.headerWithName
import org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document
import org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris
import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest
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.requestParameters

@AutoConfigureRestDocs(uriScheme = "https", uriHost = "api-jay.com") // (1)
@Import(RestDocsConfiguration::class) // (2)
class UserControllerTest {
    @Test
    fun login() {
        mockMvc.perform( // (3)
            MockMvcRequestBuilders.post("/login")
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .param("email", user.email)
                .param("password", user.password!!)
        )
            .andExpect(MockMvcResultMatchers.status().isOk)
            .andExpect(MockMvcResultMatchers.jsonPath("$.name").value("jay@joogle.lang"))
            .andExpect(MockMvcResultMatchers.jsonPath("$.email").value("blablabla"))
            .andDo(
                document(  // (4)
                    "login", // (5)
                    preprocessRequest(
                        Preprocessors.modifyUris()
                            .removePort()  // (6)
                    ),
                    requestHeaders(  // (7)
                        headerWithName("Content-Type").description("Content-Type")
                    ),
                    requestParameters(  // (8)
                        parameterWithName("password").description("비밀번호"),
                        parameterWithName("email").description("유저 이메일"),
                    ),
                    responseFields(  // (9)
                        fieldWithPath("name").type(JsonFieldType.STRING).description("유저 이름"),
                        fieldWithPath("email").type(JsonFieldType.STRING).description("유저 이메일"),
                    )
                )
            )
            .andDo(MockMvcResultHandlers.print())
    }
}

(1) 추후에 확인 가능하지만, adoc 파일들이 생성될 때 url과 scheme를 포함해 생성됩니다. 각각 디폴트는 localhost, http 인데 추후 클라이언트 개발자가 실제로 호출해야할 url과 scheme를 지정해줍니다.

(2) RestDocsConfiguration를 Import 해줍니다.

(3) 당연히 mockMvc.post(), mockMvc.get() 등등에서도 document()를 사용할 수 있을 줄 알았는데.. 안됩니다. 번거롭지만 mockMvc.perform()를 사용해주도록 합니다.

(4) 여기서부터 실질적인 Rest docs의 시작이라고 할 수 있습니다!! 실제로 adoc 파일들을 어떻게 만들것인지에 대해 작성하는 부분이라고 보면 됩니다.

(5) api-name을 작성합니다. 추가로.. 자동으로 생성해주면 좋겠지만 {api-name}.adoc 파일을 수동으로 생성해줘야합니다. 귀찮지만 src/docs/asciidoc 디렉토리를 생성해주고 {api-name}.adoc 파일을 생성해주도록 합니다.

(6) (1)에서 설명한데로, 추후 문서에는 url과 scheme가 포함되는데 port도 같이 문서로 작성됩니다. 따로 도메인이 있다면 port를 문서에서 제거하기 위해 Preprocessors.modifyUris().removePort()를 사용합니다. 그 외엔 (1)에서 port를 따로 지정해주면 됩니다.

(7) API 문서에 작성될 header 관련 정보를 작성합니다.

(8) API 문서에 작성될 requestParameter를 작성합니다. 예제엔 없지만 pathParameters()를 사용해 path params도 문서에 작성할 수 있습니다. get, post요청에 맞춰 적절하게 문서로 생성됩니다.

(9) API 문서에 작성될 responseField를 작성합니다. optional()등의 함수 체이닝을 통해 필수유무도 작성할 수 있습니다.

src/docs/asciidoc/{api-name}.adoc 템플릿 작성

위와 같이 테스트 코드를 작성 하고 src/docs/asciidoc 경로에 login.adoc 파일까지 만들어줬다면 template을 만들어야 합니다. 저는 아래와 같이 작성했습니다.

ifndef::snippets[]
:snippets: build/generated-snippets
endif::[]
= {docname} API Document
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 4
:sectlinks:

//NOTE: ""

//TIP: ""

//IMPORTANT: ""

//CAUTION: ""

//WARNING: ""

== Request
=== Curl-Reqeust
include::{snippets}/{docname}/curl-request.adoc[]
=== Http-Reqeust
include::{snippets}/{docname}/http-request.adoc[]
=== Request-Headers
include::{snippets}/{docname}/request-headers.adoc[]
=== Request-Body
include::{snippets}/{docname}/request-body.adoc[]
=== Request-Parameters
include::{snippets}/{docname}/request-parameters.adoc[]
== Response
=== Http-Response
include::{snippets}/{docname}/http-response.adoc[]
=== Response-Body
include::{snippets}/{docname}/response-body.adoc[]
=== Response-Fields
include::{snippets}/{docname}/response-fields.adoc[]

asciidoc 문법에 대해서는 링크정도만 남겨놓고 자세한 설명은 하지 않겠습니다. snippets라는 변수에 snippet 경로를 지정해준 것, include::를 사용해 {file-name}.adoc 파편들을 한 땀 한 땀 추가했다~ 정도만 이해하시고 API문서 하나가 대략적으로 이 템플릿 하나를 공유해서 사용하게 된다 정도로만 봐주시면 됩니다. 참고로, curl-request.adoc, http-request.adoc, request-body.adoc, http-response.adoc 등의 .adoc 파일들은 테스트 성공시 build/generated-snippets/{api-name} 경로에 자동 생성되는 해당 API의 파편 파일들입니다.

index.adoc 파일

이번엔 index.adoc 파일을 생성해보겠습니다. 대부분의 Rest docs 가이드를 작성한 포스팅에서 index.adoc 파일 하나에 모든 API문서를 다 때려박는것을 예제로 작성하는데.. API가 한 두개면 모를까, 늘어나면 늘어났지 줄어들지 않을것을 고려했을 때 api별로 adoc파일을 따로 관리하는것이 유지보수면에서 좋겠다고 생각이 들었습니다. 저는 그래서 index.adoc 파일엔 각 {api-name}.adoc 파일을 link만 추가해주는 방향으로 진행했습니다.

우선 src/docs/asciidoc 경로에 index.adoc 파일을 생성하고 아래와 같이 작성했습니다.

ifndef::snippets[]
:basedir: {docdir}/../../../
:snippets: build/generated-snippets
:sources-root: {basedir}/src
:resources: {sources-root}/main/resources
:resources-test: {sources-root}/test/resources
:java: {sources-root}/main/java
:java-test: {sources-root}/test/java
endif::[]
= API Document
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 5
:sectlinks:

== User API
=== link:login.html[1.Login]

이렇게 했더니 정말 보기 싫은 파란색 폰트의 언더라인까지 더해진 하이퍼 링크가 나타나더군요.(아래 이미지 참고) 아 정말 보기 싫게 생겼다.

아 정말 보기 싫게 생겼다.


..(이건 못참지).. 바로 커스텀에 들어가보도록 합니다. 뭐 엄청난 커스터마이징 작업을 할건 아니구요, 저 하이퍼링크 텍스트만 조금 노멀해 보이도록 수정해보겠습니다. 우선 root 디렉토리에 .asciidoctor 디렉토리를 만들고 그 안에 docinfo.html 파일을 추가해줍니다. 이 파일은 asciidoctor task 중 .adoc -> .html 변환(convert)작업이 진행될 때 해당파일을 참고해 추가적으로 사용자가 정의해놓은 html 구성요소들을 반영해주는 레퍼런스 파일입니다. CSS전체를 아예 다시 반영하고 싶다면 해당 디렉토리에 {css_file_name}.css 파일을 생성해 사용할 수 있습니다. 저는 docinfo.html 파일을 아래와 같이 작성했습니다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>
        a:link {
            color: #141517;
            text-decoration: none
        }

        a:hover {
            color: #F82F62;
            text-decoration: underline
        }
    </style>
</head>
<body>

</body>
</html>

그리고, index.adoc 파일을 아래와 같이 수정해줍니다.

ifndef::snippets[]
:basedir: {docdir}/../../../
:snippets: build/generated-snippets
:sources-root: {basedir}/src
:resources: {sources-root}/main/resources
:resources-test: {sources-root}/test/resources
:java: {sources-root}/main/java
:java-test: {sources-root}/test/java
endif::[]
= API Document
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 5
:sectlinks:
:docinfodir: .asciidoctor       << 추가 (1)
:docinfo: shared                << 추가 (2)

== User API
=== link:login.html[1.Login]

(1) docinfo.html 파일이 존재하는 디렉토리 경로를 지정합니다.

(2) docinfo.html 파일을 참고해 html 파일을 생성할지 선택합니다. option으로 shared뿐만 아니라 private, head, footer 등을 조합할 수 있습니다.

이제, test를 다시 한 번 돌리고 build한 다음에 index.html 파일을 열어보면!! 아래와 같이 하이퍼링크 텍스트가 비교적 노멀해진 모습을 확인할 수 있습니다. 그래도 조금은 봐줄만 하죠?

그래도 조금은 봐줄만 하죠?


해당 포스팅의 목적이었던 Restdocs 커스터마이징 작업이 끝났습니다.😂 CSS전체를 커스터마이징해서 사용하시고 싶은 분은 :docinfodir:, :docinfo: 대신 :stylesdir:, :stylesheet: 를 사용하시면 됩니다.

:docinfodir: .asciidoctor       
:docinfo: shared                

:stylesdir: .asciidoctor/{css_dir_name}
:stylesheet: {my_css_file_name}.css

저는 root 디렉토리에 .asciidoctor 디렉토리를 만들고 그 안에 docinfo.html이나 .css 파일을 넣어야 한다는 사실을 알게 될 때 까지의 삽질 과정에서 시간을 많이 소비했는데.. 이 글을 읽으시는 분들은 바로 해결됐으면 좋겠습니다.

참고

Leave a comment