SpringBoot + Restdocs

최준호·2022년 8월 22일
0

Restdocs

목록 보기
1/1
post-thumbnail

원활한 업무 진행을 위해 정리해두려는 글입니다.

참고 Spring 공식 문서 Creating API Documentation with Restdocs

📗 RestDocs 설정

restdocs의 개념은 대충 알고 있으니 바로 시작하자.

📄 gradle

plugins {
	id 'org.springframework.boot' version '2.6.3'
	id 'io.spring.dependency-management' version '1.0.12.RELEASE'
	id 'java'
	//restdocs 용 asciidoctor
	id 'org.asciidoctor.jvm.convert' version '3.3.2'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	...
    
	// restdocs 추가
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'

}

ext {
	snippetsDir = file('build/generated-snippets')	//(3) 빌드시 snippets 파일들이 저장될 저장소
}

tasks.named('test') {
	useJUnitPlatform()
	outputs.dir snippetsDir
}

asciidoctor {	//(5) asccidoctor 설정
	dependsOn test
	inputs.dir snippetsDir
}

asciidoctor.doFirst {	//(6) asciidoctor가 실행될 때 docs 하위 파일 삭제
	delete file('src/main/resources/static/docs')
}

bootJar {	//(7) bootJar 시 asciidoctor 종속되고 build하위 스니펫츠 파일을 classes 하위로 복사
	dependsOn asciidoctor
	copy {
		from "${asciidoctor.outputDir}"
		into 'BOOT-INF/classes/static/docs'
	}
}

task copyDocument(type: Copy) {		//(8) from의 파일을 into로 복사
	dependsOn asciidoctor
	from file("build/docs/asciidoc")
	into file("src/main/resources/static/docs")
}

build {		//(9) build 시 copyDocument 실행
	dependsOn copyDocument
}

gradle 설정을 다음과 같이 적용했다.

📄 테스트 코드 작성하기

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.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
import org.springframework.restdocs.operation.preprocess.Preprocessors;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.restdocs.payload.PayloadDocumentation;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.transaction.annotation.Transactional;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.kakaovx.ballmateapitest.dto.product.ProductInsertDto;

@AutoConfigureRestDocs
@AutoConfigureMockMvc
@SpringBootTest
@Transactional(readOnly = true)
class ProductInte {

   @Autowired
   private MockMvc mock;

	@Test
	void product_list_성공() throws Exception {
       ResultActions perform = mock.perform(MockMvcRequestBuilders.get("/product/all"));
       perform.andExpect(MockMvcResultMatchers.status().isOk());
       //perform.andDo(MockMvcResultHandlers.print());
       perform.andDo(MockMvcRestDocumentation.document("product/all",
       
           Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
           Preprocessors.preprocessResponse(Preprocessors.prettyPrint()),
           PayloadDocumentation.responseFields(
               PayloadDocumentation.fieldWithPath("code").type(JsonFieldType.NUMBER).description("결과 코드"),
               PayloadDocumentation.fieldWithPath("data.[].id").type(JsonFieldType.NUMBER).description("아이디 값"),
               PayloadDocumentation.fieldWithPath("data.[].create_date").type(JsonFieldType.STRING).description("생성일"),
               PayloadDocumentation.fieldWithPath("data.[].modified_date").type(JsonFieldType.STRING).description("수정일"),
               PayloadDocumentation.fieldWithPath("data.[].email").type(JsonFieldType.STRING).description("이메일"),
               PayloadDocumentation.fieldWithPath("data.[].name").type(JsonFieldType.STRING).description("이름"),
               PayloadDocumentation.fieldWithPath("data.[].picture").type(JsonFieldType.STRING).description("데이터")
           )
       ));
	}

}
{
    "code": 0,
    "data": [
        {
            "id": 1,
            "create_date": "2022-08-04T22:10:44.712903",
            "modified_date": "2022-08-04T22:10:44.712903",
            "email": "juno@mail.com",
            "name": "한글",
            "picture": "picture"
        },
        {
            "id": 2,
            "create_date": "2022-08-04T22:10:44.712903",
            "modified_date": "2022-08-04T22:10:44.712903",
            "email": "juno@mail.com",
            "name": "깨짐",
            "picture": "phone"
        },
        {
            "id": 3,
            "create_date": "2022-08-04T22:10:44.712903",
            "modified_date": "2022-08-04T22:10:44.712903",
            "email": "juno@mail.com",
            "name": "확인",
            "picture": "cup"
        },
        {
            "id": 4,
            "create_date": "2022-08-04T22:10:44.712903",
            "modified_date": "2022-08-04T22:10:44.712903",
            "email": "juno@mail.com",
            "name": "용도",
            "picture": "watch"
        }
    ]
}

반환할 형식의 데이터에 맞게 다음과 같이 작성해준다.

📄 adoc 작성

ifndef::snippets[]
:snippets: ./build/snippets
endif::[]

= TEST API
:toc: left
:toclevels: 4
:toc-title: test api

== API TEST

=== REQUEST

include::{snippets}/product/all/http-request.adoc[]

=== REQEUST FIELD

include::{snippets}/product/all/request-fields.adoc[]

=== RESPONSE

include::{snippets}/product/all/http-response.adoc[]

=== RESPONSE FIELD

include::{snippets}/product/all/response-fields.adoc[]

adoc 파일은 위 gradle에서 설정한 /src/docs/asciidoc/index.adoc파일로 작성했다.

📄 실행해보기

./gradlew clean build를 통해 테스트 코드가 모두 실행되면

그냥 테스트 코드만 실행해도 되지만 build 시에 파일이 나온다는 것을 보여주기 위해!

다음과 같이 resources/static/docs/index.html 파일이 생성된다. 해당 파일을 접근하기 위해

http://localhost:8080/docs/index.html

url로 호출을 하면

화면이 나오는데 다음과 같이 한글이 깨진 것을 확인할 수 있었다.

한글 깨짐 및 최적화 문제를 해결해보자!

📕 Restdocs 최적화

참고 Spring REST Docs 적용 및 최적화 하기

위 내용으로 이제 우리는 docs 파일을 자동으로 생성할 수 있다. 하지만 코드가 너무 길어지고 한글이 깨지는 듯 여러 문제들이 발생하고 있다. 해당 문제를 해결해보자.

📄 Config 추가

@TestConfiguration
public class RestDocsConfig {
    
    @Bean
    public RestDocumentationResultHandler handler(){
        return MockMvcRestDocumentation.document(
            "{class-name}/{method-name}",
            Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
            Preprocessors.preprocessResponse(Preprocessors.prettyPrint())
        );
    }

    public static final Attribute field(String key, String value){
        return new Attribute(key,value);
    }
}

다음 파일을 test/{project pacakge}/config/ 아래에 생성해주었다.

여기서 field는 추후에 문서를 커스텀할 때 filed값이 추가된다면 사용할 옵션이기 때문에 사용할 때 설명하겠다. 지금 궁금한 분들은 위 참고 링크를 타고 들어가서 확인해보면 된다.

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.restdocs.payload.PayloadDocumentation;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.filter.CharacterEncodingFilter;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.kakaovx.ballmateapitest.config.RestDocsConfig;
import com.kakaovx.ballmateapitest.dto.product.ProductInsertDto;

@AutoConfigureRestDocs
@AutoConfigureMockMvc
@SpringBootTest
@Import(RestDocsConfig.class)
@ExtendWith(RestDocumentationExtension.class)
@Transactional(readOnly = true)
class ProductInte {
    
    @Autowired
    private MockMvc mock;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    protected RestDocumentationResultHandler docs;

    @BeforeEach
    void setUp(final WebApplicationContext context,
               final RestDocumentationContextProvider provider) {
        this.mock = MockMvcBuilders.webAppContextSetup(context)
                .apply(MockMvcRestDocumentation.documentationConfiguration(provider))  // rest docs 설정 주입
                .alwaysDo(MockMvcResultHandlers.print()) // andDo(print()) 코드 포함 -> 3번 문제 해결
                .alwaysDo(docs) // pretty 패턴과 문서 디렉토리 명 정해준것 적용
                .addFilters(new CharacterEncodingFilter("UTF-8", true)) // 한글 깨짐 방지
                .build();
    }

	@Test
    @DisplayName("product 리스트를 불러오는데 성공한다.")
	void productAll() throws Exception {
        ResultActions perform = mock.perform(MockMvcRequestBuilders.get("/product/all").contentType(MediaType.APPLICATION_JSON));
        perform.andExpect(MockMvcResultMatchers.status().isOk());
        perform.andDo(docs.document(
            PayloadDocumentation.responseFields(
                PayloadDocumentation.fieldWithPath("code").type(JsonFieldType.NUMBER).description("결과 코드"),
                PayloadDocumentation.fieldWithPath("data.[].id").type(JsonFieldType.NUMBER).description("아이디 값"),
                PayloadDocumentation.fieldWithPath("data.[].create_date").type(JsonFieldType.STRING).description("생성일"),
                PayloadDocumentation.fieldWithPath("data.[].modified_date").type(JsonFieldType.STRING).description("수정일"),
                PayloadDocumentation.fieldWithPath("data.[].email").type(JsonFieldType.STRING).description("이메일"),
                PayloadDocumentation.fieldWithPath("data.[].name").type(JsonFieldType.STRING).description("이름"),
                PayloadDocumentation.fieldWithPath("data.[].picture").type(JsonFieldType.STRING).description("데이터")
            )
        ));
	}
}

그리고 기존 코드를 다음과 같이 변경하여 작성하였다.

여기서 주의해야할 코드는

  1. @Import(RestDocsConfig.class) 우리가 작성한 RestDocsConfig 파일을 읽어올 수 있게 해준다.
  2. @ExtendWith(RestDocumentationExtension.class) setUp에서 주입받을 RestDocumentationContextProvider를 읽어올 수 있게 해준다.
  3. andDo(docs.document()) 해당 부분이 기존에 Restdosc 자체 메서드를 사용하는 것이 아닌 우리가 설정한 docs를 불러와서 출력해야한다.

이제 한글이 깨지지 않고 출력값도 잘 가져올 수 있었다.

profile
코딩을 깔끔하게 하고 싶어하는 초보 개발자 (편하게 글을 쓰기위해 반말체를 사용하고 있습니다! 양해 부탁드려요!) 현재 KakaoVX 근무중입니다!

0개의 댓글