728x90
반응형

Spring에서 API 문서를 만들 때 Swegger, Restdocs를 쓴다. 가장 큰 차이는 Restdocs는 테스트코드가 필수라는 점이다.

TDD가 처음엔 귀찮은 작업일 순 있지만 향후 유지보수를 위해선 오히려 나를 편하게 해주는 작업이기 때문에 테스트코드 기반의 RestDocs를 사용하기로 했다.

 

RestDocs 공식 사이트

Spring REST Docs

 

1. 환경설정

Spring Boot를 이용할 땐 RestDocs를 가져오면 알아서 build gradle에 추가해준다. 

아닐 경우엔 구글에 검색하면 최신 버전에 맞게끔 설정하는 내용이 많을테니 검색해보자.

 

아래는 추가되어야 할 것들 ( 버전에 따라 다를 수도 있습니다. )

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

ext {
    set('snippetsDir', file("build/generated-snippets"))
}

configurations {
    asciidoctorExt
}

asciidoctor {//RestDoc
    inputs.dir snippetsDir
    configurations 'asciidoctorExt'
    dependsOn test
}

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

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

test {
    outputs.dir snippetsDir
    useJUnitPlatform()
}


// asccidoctor 작업 이후 생성된 HTML 파일을 static/docs 로 copy
task copyDocument(type: Copy) {
    dependsOn asciidoctor
    from file("build/docs/asciidoc")
    into file("src/main/resources/static/docs")
}

// 참고사항 //
// 공식 문서에서는 위의 ascidoctor.doFirst부터 아래 내용은 없고 이와 같은 내용만 있습니다.
// 이렇게 하면 jar로 만들어 질때 옮겨지는 것으로 IDE로 돌릴 때는 build 폴더에서만 확인이 가능합니다.
// 위 방법을 사용하면 IDE에서도 static으로 옮겨진 것을 확인할 수 있습니다.
// 위에 방법을 사용하든 아래 방법을 사용하든 편한 선택지를 사용하시면 됩니다.
//bootJar {//RestDoc
//    dependsOn asciidoctor
//    from("${asciidoctor.outputDir}/html5") {
//        into 'static/docs'
//    }
//}

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

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

 

2. 테스트 코드 작성하기

아래 코드가 일반적인 mockMvc를 사용한 테스트 코드이다.

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
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.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

@SpringBootTest
@Slf4j
@AutoConfigureRestDocs
@AutoConfigureMockMvc
class EmsConfigControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @Transactional
    void test() throws Exception {
        this.mockMvc.perform(MockMvcRequestBuilders.get("/user"))
            .andExpect(status().isOk());
            .andDo(
                document("user-get")
            )
    }
}

 

만약 Spring Sequrity RBAC를 사용 중이라면 authentication 을 위해 다음 메서드를 추가해주자.

ROLE이 "ROLE_ADMIN" 이라면 "ADMIN" 을 추가하면 된다. 여러 개를 콤마로 사용할 수도 있다.

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;

    void test() throws Exception {
        this.mockMvc.perform(MockMvcRequestBuilders.get("/user").with(user("admin").password("password").roles("ADMIN","USER")))
            .andExpect(status().isOk());
            .andDo(
                document("user-get")
            )
    }

 

먼저 Roles에 따라서 api에 접근이 잘 되는지 확인해보자.

 

3. Request Response Document Description

Request와 Response를 명세해주기 위한 메서드를 사용해준다. 필요에 따라서 작성해주면 된다.

		.andDo( // rest docs 문서 작성 시작
                        document("member-get", // 문서 조각 디렉토리 명
                                pathParameters( // path 파라미터 정보 입력
                                        parameterWithName("id").description("Member ID") 
                                ),
                                responseFields( // response 필드 정보 입력
                                        fieldWithPath("id").description("ID"),
                                        fieldWithPath("name").description("name"),
                                        fieldWithPath("email").description("email")
                                )
                        )
                )

pathParameter 나 requestFields는 다음과 같이 할 수 있다.

 

- Path Parameters 

spring에서는 Path value를 사용할 때 MockMvcRequestBuilders 보다 RestDocumentationRequestBuilders를 선호한다.

import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;

        this.mockMvc.perform(RestDocumentationRequestBuilders.get("/user/{id}",1L).with(user("admin").roles("CONFIG_READ")))
            .andExpect(status().isOk())
            .andDo(
                document("findUserByIndexId",
                    pathParameters(parameterWithName("id").description("User Id")),

 

- Query Parameters

Spring Rest Doc 3.0에 Query Parameter를 적용할 수 있는 방법이 추가되었다

this.mockMvc.perform(get("/users?page=2&per_page=100")) 
		.andExpect(status().isOk()).andDo(document("users", queryParameters(
				parameterWithName("page").description("The page to retrieve"), 
				parameterWithName("per_page").description("Entries per page") 
		)));

 

- RequestFields

requestFields(fieldWithPath("id").description("EmsConfig Id")),

 

- Post Method

Post Method를 사용할 땐 다음과 같이 쓰자. ContentType과 content를 이용해 map을 String으로 넣어주면 된다.

Map<String, Object> map = new HashMap<>();
map.put("name", "홍길동");
map.put("phone", "010-5424-6542");
map.put("address", address);

ObjectMapper objectMapper = new ObjectMapper();


        this.mockMvc.perform(MockMvcRequestBuilders.post("/ems_config")
                .with(user("admin").roles("CONFIG_READ", "CONFIG_WRITE"))
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(map))

 

type을 지정해주고 배열이나 Json Object를 아래와 같이 사용할 수도 있다.

fieldWithPath("result").type(JsonFieldType.ARRAY).description("result"), // 배열
fieldWithPath("result.id").type(JsonFieldType.NUMBER).description("User Id"), // JSON 요소
fieldWithPath("result[].item").type(JsonFieldType.STRING).description("User Items"), // 배열 안의 JSON요소

 

만약 숨기고 싶은 필드가 있다면 아래와 같이 하자.

fieldWithPath("id").description("사용자 id").ignored()

 

이제 빌드를 해보면 기본적으로 다음과 같은 조각들이 default로 생성된다.

  • curl-request.adoc
  • http-request.adoc
  • httpie-request.adoc
  • http-response.adoc
  • request body
  • response body

 

테스트 코드에 따라 추가적인 조각이 생성될 수 있다.

  • response-fields.adoc
  • request-parameters.adoc
  • request-parts.adoc
  • path-parameters.adoc
  • request-parts.adoc

이제 우린 이 조각들을 가져다 쓰면서 문서를 작성 할 것이다.

4. index.adoc

adoc 파일을 쉽게 작성하기 위해 아래 플러그인을 설치해준다.

 

src/docs/index.adoc 파일을 만들고 다음과 같이 작성해보자.

= REST Docs
backtony.github.io(부제)
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left 
:toclevels: 2
:sectlinks:

[[User-API]]
== User API

[[User-단일-조회]]
=== User 단일 조회
operation::user-get[]

 

plugin을 통해 확인해보면 잘 나오는 것을 볼 수 있다.

 

 

operation을 사용할 때 

operation::user-get[]

위와 같은 방법으로 빈 배열을 할당하면 모든 것들이 보이지만,

operation::user-get[snippets='response-fields']

이렇게 하면 원하는 스니펫만을 보여줄 수도 있다.

 

또한 문서를 더 깔끔하게 작성하려면 아래 사이트를 참고해보자.

Asciidoc 기본 사용법 (narusas.github.io)

 

Asciidoc 기본 사용법

Asciidoc의 기본 문법을 설명한다

narusas.github.io

 

5. index.html

문서를 html로 보는 방법은 몇 가지가 있는데,  위에서 설치했던 plugin으로 볼 수 있다 

html버튼을 누르면 브라우저가 뜨고 파일이 생성되는 것을 볼 수 있다.

 


23.06.28 추가 내용

6. 리팩토링

테스트 코드의 andDo(document()) 부분의 중복을 삭제하는 리팩토링을 진행해보자.

먼저 디렉토리 이름을 따로 지정해주지 않아도 되도록 아래 Configuration을 선언해준다.

utils/RestDocsConfig.java

@TestConfiguration
public class RestDocsConfig {

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

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

 

테스트 코드에서 선언되는 값들의 중복을 제거하기 위해 다음을 만든다.

Utils/ControllerTest.java

@Disabled
@WebMvcTest({
        MemberController.class,
        CommonDocController.class
})
public abstract class ControllerTest {

    @Autowired protected ObjectMapper objectMapper;

    @Autowired protected MockMvc mockMvc;

    @MockBean protected MemberRepository memberRepository;

    protected String createJson(Object dto) throws JsonProcessingException {
        return objectMapper.writeValueAsString(dto);
    }
}

 

이젠 andDo(document()) 부분을 삭제하는 코드를 만들어준다.

이 코드는 모든 테스트에서 extends 될 것이다.

@SpringBootTest
@AutoConfigureRestDocs
@AutoConfigureMockMvc
@Disabled
@Import(RestDocsConfig.class)
@ExtendWith(RestDocumentationExtension.class)
public class RestDocsTestSupport {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @Autowired
    protected JwtUtil jwtUtil;

    @Autowired
    protected RestDocumentationResultHandler restDocs;

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

 

여기서 만약 Spring Security를 적용하면 다음과같이 써야한다.

@BeforeEach
    void setUp(final WebApplicationContext context,
        final RestDocumentationContextProvider provider) throws ServletException {
        DelegatingFilterProxy delegateProxyFilter = new DelegatingFilterProxy();
        delegateProxyFilter.init(
            new MockFilterConfig(context.getServletContext(), BeanIds.SPRING_SECURITY_FILTER_CHAIN));
        
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
            .apply(
                MockMvcRestDocumentation.documentationConfiguration(provider))  // rest docs 설정 주입
            .alwaysDo(MockMvcResultHandlers.print()) // andDo(print()) 코드 포함 
            .alwaysDo(restDocs) // pretty 패턴과 문서 디렉토리 명 정해준것 적용
            .addFilters(
                new CharacterEncodingFilter("UTF-8", true),
                delegateProxyFilter
            )
            .build();
    }

 

아래는 예시 코드이다.

class MemberControllerTest extends RestDocsTestSupport {

    @Test
    public void member_page_test() throws Exception {
        Member member = new Member("backtony@gmail.com", 27, MemberStatus.NORMAL);
        PageImpl<Member> memberPage = new PageImpl<>(List.of(member), PageRequest.of(0, 10), 1);
        given(memberRepository.findAll(ArgumentMatchers.any(Pageable.class))).willReturn(memberPage);

        mockMvc.perform(
                get("/api/members")
                        .param("size", "10")
                        .param("page", "0")
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andDo(
                        restDocs.document(
                                requestParameters(
                                        parameterWithName("size").optional().description("size"), // 필수여부 false
                                        parameterWithName("page").optional().description("page") // 필수여부 false
                                )
                        )
                )
        ;
    }
    

    @Test
    public void member_create() throws Exception {
        MemberSignUpRequest dto = MemberSignUpRequest.builder()
                .name("name")
                .email("hhh@naver.com")
                .status(MemberStatus.BAN)
                .build();
        mockMvc.perform(
                post("/api/members")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(dto))
        )
                .andExpect(status().isOk())
                .andDo(
                        restDocs.document(
                                requestFields(
                                        // 앞서 작성한 RestDocsConfig의 field 메서드로 constraints를 명시
                                        fieldWithPath("name").description("name").attributes(field("constraints", "길이 10 이하")),
                                        fieldWithPath("email").description("email").attributes(field("constraints", "길이 30 이하")),
                                        fieldWithPath("status").description("Code Member Status 참조")
                                )
                        )
                )
        ;
    }
}

 

7. Request, Response Fields 커스터마이징

/test/resources/org/springframework/restdocs/templates/asciidoctor/ 경로에 snippet 파일을 추가하면 default 표를 만들 수 있다.

/default-request-fields.snippet

|===
|필드|타입|필수값|설명|제한

{{#fields}}
|{{path}}
|{{type}}
|{{^optional}}true{{/optional}}
|{{description}}
|{{#constraint}}{{constraint}}{{/constraint}}

{{/fields}}
|===

constraints 같은 경우는 다음과 같이 추가할 수 있다.

fieldWithPath("name").description("name").attributes(field("constraints", "길이 10 이하"))

 

/default-response-fields.snippet

|===
|필드|타입|설명

{{#fields}}
|{{path}}
|{{type}}
|{{description}}

{{/fields}}
|===

/default-path-parameters.snippet, /default-query-parameters.snippet

|===
|파라미터|설명

{{#parameters}}
|{{name}}
|{{description}}

{{/parameters}}
|===

 

8. Enum 코드 문서화

Enum 타입을 사용하기 위해선 로직에 사용하던 enum을 다음 interface에 implement 해주어야 한다.

Enumtype.java

public interface RestDocsEnumType {

    String getName();

    String getDescription();

}

 

아래는 예시 코드.

MemberStatus.java

@AllArgsConstructor
public enum MemberStatus implements EnumType {
    LOCK("일시 정지"),
    NORMAL("정상"),
    BAN("영구 정지");

    private final String description;

    @Override
    public String getDescription() {
        return this.description;
    }

    @Override
    public String getName() {
        return this.name();
    }
}

 

위는 참고했던 사이트에서 만든 파일들이고,

내가 직접 만들었던 파일들이다.

 

Enum에 관련한 snippet인 custom-response-fields.snippet을 만들어준다.

|===
|필드|타입|설명

{{#fields}}
|{{path}}
|{{type}}
|{{description}}

{{/fields}}
|===

 

위 패키지 경로대로 하나씩 클래스를 만들어준다.

 

utils/CustomResponseFieldsSnippet

public class CustomResponseFieldsSnippet extends AbstractFieldsSnippet {

    public CustomResponseFieldsSnippet(String type,
        PayloadSubsectionExtractor<?> subsectionExtractor,
        List<FieldDescriptor> descriptors, Map<String, Object> attributes,
        boolean ignoreUndocumentedFields) {
        super(type, descriptors, attributes, ignoreUndocumentedFields,
            subsectionExtractor);
    }

    @Override
    protected MediaType getContentType(Operation operation) {
        return operation.getResponse().getHeaders().getContentType();
    }

    @Override
    protected byte[] getContent(Operation operation) throws IOException {
        return operation.getResponse().getContent();
    }
}

 

utils/ApiResponseDto

@ToString
@Getter
@NoArgsConstructor
@Builder
public class ApiResponseDto<T> {

    private T data;

    private ApiResponseDto(T data){
        this.data=data;
    }

    public static <T> ApiResponseDto<T> of(T data) {
        return new ApiResponseDto<>(data);
    }
}

 

utils/consts/EnumDocs

@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EnumDocs {
    // 문서화하고 싶은 모든 enum값을 명시
    Map<String,String> Sex;
    Map<String,String> memberStatus;
}

 

utils/consts/CommonDocController

@ToString
@Getter
@NoArgsConstructor
@Builder
public class ApiResponseDto<T> {

    private T data;

    private ApiResponseDto(T data){
        this.data=data;
    }

    public static <T> ApiResponseDto<T> of(T data) {
        return new ApiResponseDto<>(data);
    }
}

 

utils/consts/CommonDocControllerTest

// restdocs의 get 이 아님을 주의!!
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;


class CommonDocControllerTest extends RestDocsTestSupport {

    @Test
    public void enums() throws Exception {
        // 요청
        ResultActions result = this.mockMvc.perform(
                get("/test/enums")
                        .contentType(MediaType.APPLICATION_JSON)
        );

        // 결과값
        MvcResult mvcResult = result.andReturn();

        // 데이터 파싱
        EnumDocs enumDocs = getData(mvcResult);

        // 문서화 진행
        result.andExpect(status().isOk())
                .andDo(restDocs.document(
                        customResponseFields("custom-response", beneathPath("data.memberStatus").withSubsectionId("memberStatus"), // (1)
                                attributes(key("title").value("memberStatus")),
                                enumConvertFieldDescriptor((enumDocs.getMemberStatus()))
                        ),
                        customResponseFields("custom-response", beneathPath("data.sex").withSubsectionId("sex"), 
                                attributes(key("title").value("sex")),
                                enumConvertFieldDescriptor((enumDocs.getSex()))
                        )
                ));
    }

    // 커스텀 템플릿 사용을 위한 함수
    public static CustomResponseFieldsSnippet customResponseFields
                                (String type,
                                 PayloadSubsectionExtractor<?> subsectionExtractor,
                                 Map<String, Object> attributes, FieldDescriptor... descriptors) {
        return new CustomResponseFieldsSnippet(type, subsectionExtractor, Arrays.asList(descriptors), attributes
                , true);
    }

    // Map으로 넘어온 enumValue를 fieldWithPath로 변경하여 리턴
    private static FieldDescriptor[] enumConvertFieldDescriptor(Map<String, String> enumValues) {
        return enumValues.entrySet().stream()
                .map(x -> fieldWithPath(x.getKey()).description(x.getValue()))
                .toArray(FieldDescriptor[]::new);
    }

    // mvc result 데이터 파싱
    private EnumDocs getData(MvcResult result) throws IOException {
        ApiResponseDto<EnumDocs> apiResponseDto = objectMapper
                                                .readValue(result.getResponse().getContentAsByteArray(),
                                                new TypeReference<ApiResponseDto<EnumDocs>>() {}
                                                );
        return apiResponseDto.getData();
    }
}

 

이렇게 만들고 빌드를 하면 아래와 같은 adoc 파일이 생기는데

 

Enum을 클릭해서 새 창을 띄우도록 하려면 아래와 같이 사용할 수 있다.

queryParameters(
                        parameterWithName("memberStatus").description(
                            "link:#enum-memberStatus[memberStatus,window=\"_blank\"]"),

 

 

 

 

 

 

 

 

728x90
반응형

+ Recent posts