Spring Rest docs (with JWT Token)

haazz·2024년 4월 5일

spring

목록 보기
1/5
post-thumbnail

1. 개요


저희 팀은 notion과 구글 스페레드 시트를 이용하여 문서를 공유하고 있었습니다.
그런데 문서를 계속해서 작성할 때마다 잘 못 작성하는 부분이나 실제 동작과 문서가 달라지는 문제가 발생하였습니다.
그래서 자동으로 문서화를 해주는 도구인 Rest docs를 사용해 보기로 하였습니다.
Spring Rest docs에 대한 정보가 공식 문서(https://spring.io/projects/spring-restdocs)를 제외하고는 너무 부족하다 싶어서 이렇게 블로그 글을 적게 되었습니다.

2. Swagger가 아닌 Rest docs를 사용하는 이유


API 문서화 도구 중에 가장 많이 사용되는 두 가지는 Swagger와 Rest docs가 있습니다. Swagger는 적용이 쉽지만 이전에 일반적인 문서를 이용할때와 같이 완벽한 동기화가 안될 수 있기에 Rest docs를 선택하게 되었습니다.

3. Spring Rest docs 적용법


버전

  • spring boot 3.2.2
  • gradle 8.5
  • JUnit 5
  • MockMVC
  • AsciiDoc

동작 순서

build 혹은 bootjar 실행시 test가 실행됩니다.
-> test 코드에 따라 adoc 파일을 생성합니다.
-> 생성된 adoc 파일을 지정해둔 형식에 맞게 HTML로 변형 합니다.
-> HTML 파일을 static 디렉토리 아래 저장합니다.

build.gradle

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

configurations {
	// For RestDocs
	asciidoctorExt
	compileOnly {
		extendsFrom annotationProcessor
	}
}

dependencies { // (2)
	// Rest-docs
	asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'

	// JUnit5
	testImplementation("org.junit.platform:junit-platform-launcher:1.5.2")
	testImplementation("org.junit.jupiter:junit-jupiter:5.5.2")
}


tasks.named('test') {
	// For Rest-docs
	outputs.dir snippetsDir // (3)
	useJUnitPlatform()
}

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

asciidoctor {
	configurations 'asciidoctorExt' 
	baseDirFollowsSourceFile() // (4)
	inputs.dir(snippetsDir)  // (5)
	dependsOn test // (6)
}

asciidoctor.doFirst {
	// asciidoctor가 실행될 때 기존 파일들을 삭제
	delete file('src/main/resources/static')  // (7)
}

task createDocument(type: Copy) { // (8)
	dependsOn asciidoctor
	from file("build/docs/asciidoc")
	into file("src/main/resources/static")
}

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

(1) asciidoc(.adoc) 파일을 컨버팅하여 빌드 파일에 넣기 위한 플러그인 코드
(2) asciidoctor, mockMVC, JUnit에 대한 dependency 추가
(3) asciidoc 파일을 출력할 디렉토리 설정
(4) asciidoc 파일에서 다른 asciidoc 파일을 불러올 때 경로 설정 (Gradle 7부터는 수동으로 설정 필요)
(5) 변환할 파일에 input 디렉토리 설정
(6) test -> asccidoctor 순으로 실행
(7) 새로운 HTML 파일을 만들기 전에 기존 파일들을 모두 삭제
(8) asciidoctor -> createDocument 순으로 실행, asciidoc 파일을 이용해 만든 HTML 파일을 static 폴더로 복사
(9) bootjar 혹은 build 시 createDocument 실행하고 output을 build/static/docs에 저장

src/test/**/RestDocsConfiguration

  • 저장 디렉토리 명을 "{class-name}/{method-name}"으로 지정해주었습니다.
  • prettyPrint()를 사용하면 자동으로 문서의 가독성을 높여줍니다.
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler;
import org.springframework.restdocs.operation.preprocess.Preprocessors;

@TestConfiguration
public class RestDocsConfiguration {

    @Bean
    public RestDocumentationResultHandler write() {
        return MockMvcRestDocumentation.document(
                "{class-name}/{method-name}", // 저장 디렉토리 명 정리
                // 문서 가독성 높이기
                Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
                Preprocessors.preprocessResponse(Preprocessors.prettyPrint())
        );
    }
}

src/test/**/AbstractRestDocsTests

모든 test 코드에 공통으로 사용될 부분을 Abstract로 초기화 해주고 실제 코드에서 extends 하였습니다.
RestDocumentationResultHandler를 사용하면 Spring test에서 알아서 request와 response에 대해 알아서 문서를 생성해주게 됩니다.
setup에서 테스트 결과 출력과 handler를 alwaysDo로 항상 실행하게 만들었습니다.

import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.filter.CharacterEncodingFilter;

@Import(RestDocsConfiguration.class)
@ExtendWith(RestDocumentationExtension.class)
public abstract class AbstractRestDocsTests {

    // 요청값과 응답값을 분석하여 자동으로 넣어줌
    @Autowired
    protected RestDocumentationResultHandler restDocs;

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper = new ObjectMapper();

    @BeforeEach
    void setUp(
            final WebApplicationContext context,
            final RestDocumentationContextProvider restDocumentation) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(documentationConfiguration(restDocumentation))
                .alwaysDo(MockMvcResultHandlers.print())
                .alwaysDo(restDocs)
                .addFilters(new CharacterEncodingFilter("UTF-8", true))
                .build();
    }
}

src/main/**/TestController

테스트할 controller입니다.
간단하게 JSON에 message를 담아 return 하는 코드입니다.
인증은 JWT token을 사용하였습니다.

@RestController
@RequestMapping("/api/v1/test")
public class TestController {
    @GetMapping("/hello")
    public ResponseEntity<ApiResponseEntity> hello() {
        return ApiResponseEntity.toResponseEntity(
                TestDto.TestResponse.builder().message("Hello!").build());
    }

    @GetMapping("/user")
    @PreAuthorize("hasRole('USER')")
    public ResponseEntity<ApiResponseEntity> userEndPoint() {
        return ApiResponseEntity.toResponseEntity(
                TestDto.TestResponse.builder().message("ONLY user can see this").build());
    }
}

src/test/TestControllerTest

이제 Restdocs를 생성하기 위한 코드입니다.
이전에 만들어 두었던 AbstractRestDocsTests를 extends 하여 사용하였습니다.
저는 아래와 같이 generateToken을 사용하여 토큰을 생성하려 하였지만 테스트에서는 SpringSecurity가 작동하지 않기 때문에 null값이 return 되고 인증 실패도 일어나지 않습니다. 그렇기에 Authorization에 원하시는 문자열을 넣고 진행하셔도 무방합니다.
주의할 점: 만들고자 하는 controller에 사용되는 모든 service를 MockBean으로 선언해야 오류가 나오지 않습니다.

import com.smusoak.restapi.controllers.TestController;
import com.smusoak.restapi.filters.JwtAuthenticationFilter;
import com.smusoak.restapi.models.Role;
import com.smusoak.restapi.models.User;
import com.smusoak.restapi.restdocs.AbstractRestDocsTests;
import com.smusoak.restapi.services.JwtService;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.MockBeans;


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

// TestController에 Rest Docs를 만들기 위한 테스트케이스
@WebMvcTest(TestController.class)
@MockBeans({
        @MockBean(JwtAuthenticationFilter.class)
})
public class TestControllerTest extends AbstractRestDocsTests {

    @MockBean
    JwtService jwtService;

    @Test
    void HelloTest() throws Exception {
        mockMvc.perform(get("/api/v1/test/hello"))
                .andExpect(status().isOk());
    }

    @Test
    void UserTest() throws Exception {
        mockMvc.perform(get("/api/v1/test/user")
                .header("Authorization", "Bearer " +
                        jwtService.generateToken(User
                                .builder()
                                .mail("tmp")
                                .build()))
                )
                .andExpect(status().isOk());
    }
}

adoc 문서 작성

/src/docs/asciidoc/index.adoc
index.adoc 파일에서 test.adoc 파일을 include 합니다.
제 코드를 따라오신 분이라면 반드시 src/docs/asciidoc/ 디렉토리 밑에 adoc 파일을 생성하여야 합니다!
asciidoc 문법은 자료가 많아 따로 정리하지는 않았습니다.

= API Document
:doctype: book
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:

include::test.adoc[]

/src/docs/asciidoc/test.adoc
AbstractRestDocsTests에서 지정해준 경로에 맞게 snippet을 가져와 생성해주면 됩니다.

== Test

=== 테스트 Hello
operation::test-controller-test/hello-test[snippets='http-request,http-response']

=== 테스트 User
operation::test-controller-test/user-test[snippets='http-request,http-response']

결과

4. 막힌 부분들


이렇게 해서 Rest docs 제작이 완료되었습니다.
여기 부터는 제가 제작하면서 막혔던 부분에 대해서 포스팅 해보려고 합니다.

Form-data(MultipartFile) request로 보내기

controller에서 Form-data를 통해 파일과 JSON을 동시에 받는 형태로 코드를 제작하였습니다.
그에 대한 Restdocs 코드는 다음과 같습니다.
실제 파일 없이 임의의 파일 객체를 생성하여 사용하였습니다.

@Test
void UpdateUserImgTest() throws Exception {
    ImgDto.UpdateUserImgRequest updateUserImgRequest = new ImgDto.UpdateUserImgRequest();
    updateUserImgRequest.setMail("tmp@test.test");

    MockMultipartFile file = new MockMultipartFile("file", "tmp.png", "multipart/form-data",
            "uploadFile".getBytes(StandardCharsets.UTF_8));
    MockMultipartFile info = new MockMultipartFile("info", null, "application/json",
            objectMapper.writeValueAsString(updateUserImgRequest).getBytes(StandardCharsets.UTF_8));

    mockMvc.perform(multipart(HttpMethod.POST, "/update/img")
                    .file(file)
                    .file(info)
                    .contentType(MediaType.MULTIPART_FORM_DATA)
                    .accept(MediaType.APPLICATION_JSON)
            )
            .andExpect(status().isOk())
            .andDo(
                    restDocs.document(
                            requestParts(
                                    partWithName("file").description("이미지 파일"),
                                    partWithName("info").description("이미지 정보 (JSON)")
                            ),
                            requestPartFields("info",
                                    fieldWithPath("mail").type(JsonFieldType.STRING).description("메일")
                            ),
                            responseFields(
                                    fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("성공 여부"),
                                    fieldWithPath("data").type(JsonFieldType.NULL).description("반환되는 데이터 없음")
                            )
                    )
            );
}

JSON 내부 list mapping

JSON 내부에 list가 아래와 같이 존재한다면 다음과 같은 코드로 mapping합니다.

{
  "success" : true,
  "data" : [ {
    "mail" : "tmp1",
    "url" : "URL"
  }, {
    "mail" : "tmp2",
    "url" : "URL"
  }, {
    "mail" : "tmp3",
    "url" : "URL"
  } ]
}

@Test
void GetUserImgTest() throws Exception {
    mockMvc.perform(get("/get/url")
                    .contentType(MediaType.APPLICATION_JSON)
                    )
            .andExpect(status().isOk())
            .andDo(
                    restDocs.document(
                            responseFields(
                                    fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("성공 여부")
                            ).andWithPrefix("data.[].",
                                    fieldWithPath("mail").type(JsonFieldType.STRING).description("메일"),
                                    fieldWithPath("url").type(JsonFieldType.STRING).description("링크"),
                            )
                    )
            );
}

Response<byte[]>

이미지 파일을 다운로드 하는 부분에서 바이트 어레이를 response에 담아 반환하는 형태로 controller를 제작하였습니다.
찾아보니 Rest docs에서는 byte[]에 대한 문서화를 지원하지 않는 것을 알게되었습니다.
때문에 request만 생성하여 response에 대한 설명을 adoc 파일에 직접 추가하였습니다.

참고

profile
Developers who create benefit social values

0개의 댓글