본문 바로가기
개발/Spring

[Spring] Spring Rest Docs로 API 문서 자동화하기

by baau 2023. 7. 3.

SWM에서 프로젝트를 진행하는 과정에서 API 문서 자동화를 위해 Spring Rest Docs를 적용하기로 했다.

이전에는 Swagger나 노션에 API 문서를 작성했지만, 아래와 같은 단점이 있었고 여러 단점을 해결하기 위해 Spring Rest Docs를 적용하기로 결정하였다.

 

  • Swagger는 프로덕션 코드(컨트롤러 레이어, 요청/응답 객체)에 Swagger 애노테이션이 추가되어 가독성이 떨어졌다.
  • 테스트 기반 아니기 때문에, API 문서대로 기능이 동작한다는 보장이 없었다.
  • 노션에 API 문서를 작성 시, 새로운 코드를 개발하거나 기존 코드를 변경하였을 때 코드와 문서를 동기화하는 과정이 번거로웠다.

따라서 이번 포스트에서는 Spring Rest Docs를 적용하는 과정을 기록하고자 한다.

 

1) build.gradle 설정

plugins {
    ...
	
    id "org.asciidoctor.jvm.convert" version "3.3.2"
}

configurations {
    ...
    
    asciidoctorExt
}

dependencies {
    ...
    
    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}

ext {
    snippetsDir = file('build/generated-snippets')
}

test {
    outputs.dir snippetsDir
}

asciidoctor {
    inputs.dir snippetsDir
    configurations 'asciidoctorExt'

    sources {
        include("**/index.adoc")
    }
    baseDirFollowsSourceFile()
    dependsOn test
}

bootJar {
    dependsOn asciidoctor
    from("${asciidoctor.outputDir}") {
        into 'static/docs'
    }
}

기존에 있던 코드들은 제외하고, Spring Rest Docs를 적용하기 위해 필요한 코드만 작성하였다.

 

2) Controller Test Code 작성

@WebMvcTest(BookController.class)
@AutoConfigureRestDocs
@MockBean(JpaMetamodelMappingContext.class)
class BookControllerTest {

    @MockBean
    private BookService bookService;

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @DisplayName("새로운 도서를 등록한다.")
    @Test
    void create() throws Exception {
        // given
        BookCreateRequest request = createBookCreateRequest();
        String requestJson = objectMapper.writeValueAsString(request);
        given(bookService.getRegisteredOrNewBook(request.toServiceRequest()))
                .willReturn(any(Book.class));

        // when
        ResultActions actions = mockMvc.perform(post("/books")
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestJson));

        // then
        actions
                .andExpect(status().is2xxSuccessful())
                .andExpect(jsonPath("$.status").value("201 CREATED"))
                .andExpect(jsonPath("$.message").value("도서 등록에 성공했습니다."))
                .andExpect(jsonPath("$.data").isEmpty());

        // document
        actions
                .andDo(document("book-create", // (1)
                        preprocessRequest(prettyPrint()), // (2)
                        preprocessResponse(prettyPrint()), // (2)
                        requestFields( // (3)
                                fieldWithPath("title").type(JsonFieldType.STRING).description("제목"),
                                fieldWithPath("description").type(JsonFieldType.STRING).description("설명"),
                                fieldWithPath("isbn10").type(JsonFieldType.STRING).description("ISBN10"),
                                fieldWithPath("isbn13").type(JsonFieldType.STRING).description("ISBN13"),
                                fieldWithPath("publisher").type(JsonFieldType.STRING).description("출판사"),
                                fieldWithPath("publicationDate").type(JsonFieldType.STRING).description("출판일"),
                                fieldWithPath("price").type(JsonFieldType.NUMBER).description("가격"),
                                fieldWithPath("thumbnailUrl").type(JsonFieldType.STRING).description("썸네일 URL"),
                                fieldWithPath("authors").type(JsonFieldType.ARRAY).description("저자"),
                                fieldWithPath("translators").type(JsonFieldType.ARRAY).description("번역가")
                        ),
                        responseFields( // (4)
                                fieldWithPath("status").type(JsonFieldType.STRING).description("상태 코드"),
                                fieldWithPath("message").type(JsonFieldType.STRING).description("메시지"),
                                fieldWithPath("data").type(JsonFieldType.NULL).description("데이터")
                        )
                ));
    }

    private BookCreateRequest createBookCreateRequest() {
        return BookCreateRequest.builder()
                .title("title")
                .description("description")
                .isbn10("isbn10")
                .isbn13("isbn13")
                .publisher("publisher")
                .price(10000)
                .publicationDate(LocalDateTime.now())
                .translators(List.of("translator1", "translator2"))
                .authors(List.of("author1", "author2"))
                .thumbnailUrl("thumbnailUrl")
                .build();
    }
}

 

Spring Rest Docs는 테스트 기반이기 때문에 Controller Layer에 대한 테스트 코드를 반드시 작성해야 한다. 따라서 컨트롤러 레이어의 단위 테스트를 위해 @WebMvcTest와 @MockBean을 사용하였고, Rest Docs를 만들기 위해 @AutoConfigureRestDocs를 추가해야 한다.

 

(1) ./build/generated-snippets/ 하위에 지정한 문자열("book-create")의 폴더 하위에 문서가 작성된다.

(2) 기본적으로 json은 한줄로 취급되어 API 문서에 작성되는데, json을 가독성 좋게 문서화하기 위해서 prettyPrint()를 사용한다.

(3) requestFields는 HTTP Request Body에 포함되는 데이터를 명시하며, type, description, optional 등 지정할 수 있다.

(4) responseFileds는 HTTP Response Body에 포함되는 데이터를 명시하며, type, description, optional 등 지정할 수 있다.

 

테스트를 실행시키면, 위와 같이 ./bulid/generated-snippets/book-create가 생성되는 것을 확인할 수 있다.

 

3) Request Fields와 Response Fields Template 지정 

(1) test/resources/org/springframework/restdocs/templates 하위에 request-fields.snippet 생성

==== Request Fields
|===
|Path|Type|Optional|Description

{{#fields}}

|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{#optional}}O{{/optional}}{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}

{{/fields}}

|===

 

(2) test/resources/org/springframework/restdocs/templates 하위에 response-fields.snippet 생성

==== Response Fields
|===
|Path|Type|Optional|Description

{{#fields}}

|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{#optional}}O{{/optional}}{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}

{{/fields}}

|===

optional로 지정한 필드에 대해서는 "O" 로 표시하는 Template이다. 추가적으로 프로젝트 상황에 맞추어 커스텀할 수 있다.

 

4) index.adoc 파일 만들기 (여러 API 문서를 한 adoc 파일로 합치는 과정)

(1) src/docs/asciidoc/api/book 하위에 book.adoc 생성

  • ./bulid/generated-snippets/book-create에서 API 문서로 포함하고 싶은 내용을 include 하면 된다.
  • 예시로는 http-request.adoc, request-fields.adoc, http-response.adoc, response-fields.adoc을 include 하였다.
[[book-create]]
=== 도서 등록

==== HTTP Request
include::{snippets}/book-create/http-request.adoc[]
include::{snippets}/book-create/request-fields.adoc[]

==== HTTP Response
include::{snippets}/book-create/http-response.adoc[]
include::{snippets}/book-create/response-fields.adoc[]

 

(2) src/docs/asciidoc 하위에 index.adoc 파일을 생성

ifndef::snippets[]
:snippets: ../../build/generated-snippets
endif::[]
= Mybrary REST API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:

[[BOOK-API]]
== Book API

include::api/book/book.adoc[]

[[MYBOOK-API]]
== MYBOOK API

include::api/mybook/mybook.adoc[]

[[SEARCH-API]]
== SEARCH API

include::api/search/search.adoc[]

 

5) Rest Docs 확인하기

(1) build

(2) build/libs 하위의 jar 파일 확인

(3) jar 파일 실행 :  java -jar book-service-0.0.1-SNAPSHOT.jar

(4) localhost:8080/docs/index.html 접속