Skip to content

Spring Rest Docs 적용기

봄 edited this page Jul 26, 2022 · 5 revisions

개요

*본 문서는 Rest Docs 가 무엇인지가 아니라, 프로젝트에서의 Rest Docs 사용법을 설명합니다.

본 문서는 아래와 같은 목차를 갖습니다.

  1. Rest Docs 간단 설명
  2. 프로젝트에 Rest Docs 적용 방법 설명 a. build.gradle에 Rest Docs 관련 의존성을 추가한다. b. test/controller 패키지 내부에 RestDocsSupport.class, RestDocsConfiguration.class 파일을 생성한다. c. tc를 생성합니다.





Rest Docs 간단 설명

  • 테스트 코드 기반으로 API 문서를 생성
  • 테스트 코드가 실패하면 문서가 만들어지지 않기 때문에 테스트 코드로 검증된 문서를 보장할 수 있다.





프로젝트에 Rest Docs 적용

1. 의존성 적용하기

build.gradle에 Rest Docs 관련 의존성을 추가합니다.

build.gradle

plugins {
    **(생략)**
    // asciidoctor 플러그인 적용(gradle7 버전 이상부터는 jvm 사용)
		id "org.asciidoctor.jvm.convert" version "3.3.2"
}

group = 'com.pickpick'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    asciidoctorExtensions  // dependencies 에서 적용한거 추가
    compileOnly {
        extendsFrom annotationProcessor
    }
    querydsl.extendsFrom compileClasspath
}

repositories {
    mavenCentral()
}

dependencies {
    **(생략)**
    // asciidoctor extension 추가
		// 최종 .adoc 파일에서 operation을 사용하여 snippet 조각들을 연동할 수 있음
		asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
    // mockMvc 를 사용하여 restdocs 하도록
		testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}

ext {
		//아래에서 사용할 변수 선언
    snippetsDir = file('build/generated-snippets')
}

test {
		// test의 output의 결과를 위에서 선언한 변수(build/generated-snippets)에 추가
    outputs.dir snippetsDir
    useJUnitPlatform()
}

// asciidoctor 설정
asciidoctor {
    configurations 'asciidoctorExtensions'
    inputs.dir snippetsDir
    dependsOn test
}

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

// bootJar 시 실행
bootJar {
    dependsOn asciidoctor
    copy {
        from "${asciidoctor.outputDir}"
        into 'static/docs'
    }
}

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

// build시 copydocument 실행
build {
    dependsOn copyDocument
}

tasks.named('test') {
    useJUnitPlatform()
}





도와주는 코드인 RestDocsConfiguration, RestDocsTestSupport 작성

/test/java/com.pickpick/controller/RestDocsConfiguration.class

@TestConfiguration
public class RestDocsConfiguration {

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

/test/java/com.pickpick/controller/RestDocsTestSupport.class

package com.pickpick.controller;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ExtendWith(RestDocumentationExtension.class)
@Import(RestDocsConfiguration.class)
class RestDocsTestSupport {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected RestDocumentationResultHandler restDocs;

    @Autowired
    private ResourceLoader resourceLoader;

    @Autowired
    protected ObjectMapper objectMapper;

    @BeforeEach
    void setUp(
            final WebApplicationContext context,
            final RestDocumentationContextProvider provider
    ) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(MockMvcRestDocumentation.documentationConfiguration(provider))
                .alwaysDo(MockMvcResultHandlers.print())
                .alwaysDo(restDocs)
                .build();
    }

    protected static String readJson(final String path) throws IOException {
        return new String(Files.readAllBytes(Paths.get("src/test/resources", path)));
    }
}

ㄴ> 이 클래스는 앞으로 rest docs에서 사용할 controller test에서 상속받아서 사용할 것이다.



테스트 코드 작성

@Sql({"/truncate.sql", "/channel.sql", "/channel-subscription.sql"})
class ChannelControllerTest extends RestDocsTestSupport {

    @DisplayName("구독 여부를 포함하여 채널을 조회한다.")
    @Test
    void findAll() throws Exception {
        mockMvc.perform(RestDocumentationRequestBuilders
                        .get("/api/channels")
                        .header(HttpHeaders.AUTHORIZATION, "Bearer 2"))
                .andExpect(status().isOk())
                .andDo(restDocs.document(
                        **requestHeaders(
                                headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)")
                        ),
                        responseFields(
                                fieldWithPath("channels.[].id").type(JsonFieldType.NUMBER).description("채널 아이디"),
                                fieldWithPath("channels.[].name").type(JsonFieldType.STRING).description("채널 이름"),
                                fieldWithPath("channels.[].isSubscribed").type(JsonFieldType.BOOLEAN).description("채널 구독 여부")
                        )**
                ));
    }
}

.andDo() 내부 함수에서 requestHeaders, responseFields 등으로 docs에 대한 설정을 해준다.



.adoc 파일 자동 생성된 것 확인

1

위의 테스트코드를 실행하면 backend/build/generated-snippets/{컨트롤러_테스트_이름}/{테스트_함수_이름}.adoc 파일이 생긴다.



pickpick-api.adoc 파일 생성 후 자동생성된 .adoc 파일에서 사용하고싶은코드 넣기

Untitled

src/docs/asciidoc/{파일이름}.adoc 을 생성한다.

= PickPick(a.k.a 줍줍) API Document
pickpick team
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:

[[introduction]]
== 소개
줍줍 API 문서 입니다.

[[introduction]]
== 서비스환경
해당 API서비스의 서버 도메인 환경은 다음과 같습니다.

== Domain
|===
| 환경 | URI

| 개발서버
| `????`

| 운영서버
| `????`
|===

= 채널
== 채널 API
=== 전체 채널 조회 API

operation::channel-controller-test/find-all[snippets='http-request,request-headers,http-response,response-fields']

.adoc 파일 내부에 operation::channel-controller-test/find-all[snippets='http-request,request-headers,http-response,response-fields'] 을 작성해야 docs 문서에서 확인할 수 있다.

operation::{컨트롤러-테스트-이름}/{테스트-함수-이름}[snippets={종류1},{종류2}] 형식으로 작성한다.



문서를 작성완료하면 build하고 서버를 띄운다. http://localhost:8080/docs/api.html 로 접속하면 만들어진 rest docs를 확인할 수 있다.





snippets={}에 어떤 adoc 파일 목록을 넣어야할까?

image

컨트롤러에 테스트를 작성하고 테스트를 실행하면 build/generated-snippets에 위 사진과 같이 .adoc 파일이 생긴다. 이 파일 중 api 문서에 어떤 .adoc을 포함해야할까?

1. 모든 파일에 있으면 좋을 것 -> HttpRequest(http-request.adoc), HttpResponse(http-response.adoc)

image

operation::channel-controller-test/find-all[snippets='http-request,http-response'] 로 넣어준다면 api docs에서 위 내용을 확인할 수 있다.

기본적인 http-request와 http-response는 파일 내부에 존재하도록 하는 것이 좋겠다.

2. 해당 API 에 pathVariable이 존재하는 경우 -> PathParameters(path-parameters.adoc)

=> 이 때 주의사항: mockMvcBuilder가 아니라 RestDocumentationRequestBuilders를 사용하기

import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;

//생략
.andDo(restDocs.document(
                     pathParameters(
                              parameterWithName("id").description("채널 아이디")
                     )
           )               
);

컨트롤러 테스트 코드에서 .andDo() 내부에 pathParameters를 정의해준 후 api.adoc 파일을 수정한 후 실행하면 api docs에서 아래 이미지를 확인할 수 있다.

operation::channel-controller-test/find-all[snippets='path-parameters']

image



3. 해당 API 에 requestParam이 존재하는 경우 -> RequestParameters(request-parameters.adoc)

.andDo(restDocs.document(
                     requestParameters(
                              parameterWithName("keyword").optional().description("검색할 키워드"),
                              parameterWithName("date").optional().description("검색 기준 날짜"),
                              parameterWithName("channelIds").optional().description("검색할 채널 아이디(복수 가능)"),
                              parameterWithName("needPastMessage").optional().description("불러올 메시지가 더 존재하는지"),
                              parameterWithName("messageId").optional().description("메시지 아이디"),
                              parameterWithName("messageCount").optional().description("한 번에 불러올 메시지 개수(default:20)")
                     )
           )
);

컨트롤러 테스트 코드에서 .andDo() 내부에 requestParameters를 정의해준 후 api.adoc 파일을 수정한 후 실행하면 api docs에서 아래 이미지를 확인할 수 있다.

operation::channel-controller-test/find-all[snippets='request-parameters']

image

### 4. 해당 API 에 requestBody가 존재하는 경우 -> RequestFields (request-fields)
.andDo(restDocs.document(
    requestFields(
		        fieldWithPath("[].id").type(JsonFieldType.NUMBER).description("채널 아이디"),
		        fieldWithPath("[].order").type(JsonFieldType.NUMBER).description("구독 채널 순서")
        )
    )
);

컨트롤러 테스트 코드에서 .andDo() 내부에 requestFields를 정의해준 후 api.adoc 파일을 수정한 후 실행하면 api docs에서 아래 이미지를 확인할 수 있다.

operation::channel-controller-test/find-all[snippets='request-fields']

image

*실제 RequestBody 예시

image



5. 해당 API에 ResponseBody가 존재하는 경우 → ResponseFields(response-fields)

.andDo(restDocs.document(
        responseFields(
                fieldWithPath("channels.[].id").type(JsonFieldType.NUMBER).description("채널 아이디"),
                fieldWithPath("channels.[].name").type(JsonFieldType.STRING).description("채널 이름"),
                fieldWithPath("channels.[].isSubscribed").type(JsonFieldType.BOOLEAN).description("채널 구독 여부")
        )
    )
);

컨트롤러 테스트 코드에서 .andDo() 내부에 responseFields를 정의해준 후 api.adoc 파일을 수정한 후 실행하면 api docs에서 아래 이미지를 확인할 수 있다.

operation::channel-controller-test/find-all[snippets='response-fields']

image

*실제 ResponseBody 예시

image



6. 해당 API 에 AuthorizationToken 처럼 헤더 정보가 존재하는 경우 -> RequestHeader(request-headers)

.andDo(restDocs.document(
    requestHeaders(
        headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)")
		)
	)
);

컨트롤러 테스트 코드에서 .andDo() 내부에 requestHeaders를 정의해준 후 api.adoc 파일을 수정한 후 실행하면 api docs에서 아래 이미지를 확인할 수 있다.

operation::channel-controller-test/find-all[snippets='request-headers']

image
Clone this wiki locally