GrowMe

[API 문서화] Spring Rest Docs를 통한 API 문서화 본문

About Spring

[API 문서화] Spring Rest Docs를 통한 API 문서화

오늘도 타는중 2022. 8. 19. 23:44
Spring Rest Docs를 통한 API 문서화
# API 문서화
# Spring Rest Docs
# 스니핏
# Spring Rest Docs를 통한 API 문서화 흐름
# Spring Rest Docs를 통해 API 문서화 해보기

*API 문서화

  • 개발 진행 시, 백엔드 쪽에서 서버 애플리케이션을 구현하고나면, 협업을 위해서는 이를 사용할 수 있도록 프론트엔드 쪽에 안내를 해주어야 한다.
  • API 문서화란 이처럼 만들어낸 API의 사용법을 안내할 수 있도록 보기 편하게 문서로 만드는 것을 말한다.
  • API 사용을 위해 어떤 정보들이 담겨 있는 문서를 API 문서 또는 API 스펙(사양 : Specification)이라고 한다.

*API 문서화 자동화 Tool

- Swagger

  • 장점 : Swagger는 Postman을 통해 API를 테스트 하듯이, API 문서 안에서 Execute 버튼을 눌러 실제로 요청을 전송해볼 수 있습니다.
@ApiOperation(value = "회원 정보 API", tags = {"Member Controller"}) // (1)
@RestController
@RequestMapping("/v11/swagger/members")
@Validated
@Slf4j
public class MemberControllerSwaggerExample {
    private final MemberService memberService;
    private final MemberMapper mapper;

    public MemberControllerSwaggerExample(MemberService memberService, MemberMapper mapper) {
        this.memberService = memberService;
        this.mapper = mapper;
    }

    // (2)
    @ApiOperation(value = "회원 정보 등록", notes = "회원 정보를 등록합니다.")

    // (3)
    @ApiResponses(value = {
            @ApiResponse(code = 201, message = "회원 등록 완료"),
            @ApiResponse(code = 404, message = "Member not found")
    })
    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberDto.Post memberDto) {
        Member member = mapper.memberPostToMember(memberDto);
        member.setStamp(new Stamp()); // homework solution 추가

        Member createdMember = memberService.createMember(member);

        return new ResponseEntity<>(
                new SingleResponseDto<>(mapper.memberToMemberResponse(createdMember)),
                HttpStatus.CREATED);
    }

    ...
    ...

    // (4)
    @ApiOperation(value = "회원 정보 조회", notes = "회원 식별자(memberId)에 해당하는 회원을 조회합니다.")
    @GetMapping("/{member-id}")
    public ResponseEntity getMember(
            @ApiParam(name = "member-id", value = "회원 식별자", example = "1")  // (5)
            @PathVariable("member-id") @Positive long memberId) {
        Member member = memberService.findMember(memberId);
        return new ResponseEntity<>(
                new SingleResponseDto<>(mapper.memberToMemberResponse(member))
                                    , HttpStatus.OK);
    }

    ...
    ...
}
@ApiModel("Member Post")  // (1)
@Getter
public class MemberPostDto {
    // (2)
    @ApiModelProperty(notes = "회원 이메일", example = "hgd@gmail.com", required = true)
    @NotBlank
    @Email
    private String email;

    // (3)
    @ApiModelProperty(notes = "회원 이름", example = "홍길동", required = true)
    @NotBlank(message = "이름은 공백이 아니어야 합니다.")
    private String name;

    // (4)
    @ApiModelProperty(notes = "회원 휴대폰 번호", example = "010-1111-1111", required = true)
    @Pattern(regexp = "^010-\\d{3,4}-\\d{4}$",
            message = "휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.")
    private String phone;
}
  • 단점 : Swagger를 사용하면 API 문서 자동화를 위해서는 위처럼 실제 코드 작성 시 코드마다 애너테이션과 문서 작성 내용을 추가해주어야 합니다. 이 때문에 본래 코드가 길어질수록 가독성이 떨어질 수 있으며 리펙토링하거나 기능 추가 시, 헷갈릴 수 있습니다.

- Spring Rest Docs

@WebMvcTest(MemberController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureRestDocs
public class MemberControllerRestDocsTest {
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private MemberService memberService;

    @MockBean
    private MemberMapper mapper;

    @Autowired
    private Gson gson;

    @Test
    public void postMemberTest() throws Exception {
        // given
        MemberDto.Post post = new MemberDto.Post("hgd@gmail.com",
                "홍길동",
                "010-1234-5678");
        String content = gson.toJson(post);

        MemberDto.response responseDto =
                new MemberDto.response(1L,
                        "hgd@gmail.com",
                        "홍길동",
                        "010-1234-5678",
                        Member.MemberStatus.MEMBER_ACTIVE,
                        new Stamp());

        // willReturn()이 최소 null은 아니어야 한다.
        given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class)))
                .willReturn(new Member());

        given(memberService.createMember(Mockito.any(Member.class)))
                .willReturn(new Member());

        given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(responseDto);

        // when
        ResultActions actions =
                mockMvc.perform(
                        post("/v11/members")
                                .accept(MediaType.APPLICATION_JSON)
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(content)
                );

        // then
        actions
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.data.email").value(post.getEmail()))
                .andExpect(jsonPath("$.data.name").value(post.getName()))
                .andExpect(jsonPath("$.data.phone").value(post.getPhone()))
                .andDo(document("post-member",    // =========== (1) API 문서화 관련 코드 시작 ========
                        getRequestPreProcessor(),
                        getResponsePreProcessor(),
                        requestFields(
                                List.of(
                                        fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
                                        fieldWithPath("name").type(JsonFieldType.STRING).description("이름"),
                                        fieldWithPath("phone").type(JsonFieldType.STRING).description("휴대폰 번호")
                                )
                        ),
                        responseFields(
                                List.of(
                                        fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
                                        fieldWithPath("data.memberId").type(JsonFieldType.NUMBER).description("회원 식별자"),
                                        fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"),
                                        fieldWithPath("data.name").type(JsonFieldType.STRING).description("이름"),
                                        fieldWithPath("data.phone").type(JsonFieldType.STRING).description("휴대폰 번호"),
                                        fieldWithPath("data.memberStatus").type(JsonFieldType.STRING).description("회원 상태"),
                                        fieldWithPath("data.stamp").type(JsonFieldType.NUMBER).description("스탬프 갯수")
                                )
                        )
                ));   // =========== (2) API 문서화 관련 코드 끝========
    }
}
  • Spring Rest Docs를 사용하여 API 문서화를 하면, 위처럼 별도의 TestCase를 통해 문서화의 자동화가 진행됩니다. 이로써 본래 코드는 전혀 영향이 가지 않는다는 장점이 있습니다. 
  • 하지만, 문서화를 하려면 TestCase를 필히 작성해주어야 하며, 테스트가 fail이되면 문서화가 되지 않습니다.
  • 그런데, Test가 통과될 때만 문서화가 된다는 의미는, 실제로 만들어지는 문서 내의 API 문서 정보와 구현해낸 애플리케이션의 API 스펙 정보가 항상 일치한다는 의미입니다. 이는 사실 장점이 될 수도 있는 부분인 것입니다. (Swagger는 API 스펙 정보를 문자열로 타이핑하는 경우가 많아 문서와 애플리케이션 사이의 API 스펙정보가 불일치할 가능성이 있습니다.)
  • 또한 Swagger처럼 API 문서 내에서 직접 requst 요청을 보낼 수는 없다는 단점도 있습니다.

*Spring Rest Docs의 API 문서 생성 흐름

  1. 슬라이스 테스트 코드 작성
  2. API 스펙 정보(Request Body, Response Body, Query Parameter 등)를 코드로 작성
  3. test 실행(Gradle의 빌드 태스크 test task 실행) -> 테스트 통과 시, API 문서 스니핏 생성(.adoc 확장자)
    -> 테스트 실패 시, 테스트 케이스 수정 Or 오류 해결
  4. 생성된 API 문서 스니핏(문서의 일부 조각)을 모아 API 문서 생성
  5. API 문서를 HTML 파일로 변환

*Spring Rest Docs로 API 문서화해보기

- build.gradle 설정

plugins {
	id 'org.springframework.boot' version '2.7.1'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id "org.asciidoctor.jvm.convert" version "3.3.2"    // (1)
	id 'java'
}

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

repositories {
	mavenCentral()
}

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

// (3)
configurations {
	asciidoctorExtensions
}

dependencies {
       // (4)
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
  
  // (5) 
	asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'

	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	implementation 'org.mapstruct:mapstruct:1.5.1.Final'
	annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.1.Final'
	implementation 'org.springframework.boot:spring-boot-starter-mail'

	implementation 'com.google.code.gson:gson'
}

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

// (7)
tasks.named('asciidoctor') {
	configurations "asciidoctorExtensions"
	inputs.dir snippetsDir
	dependsOn test
}

// (8)
task copyDocument(type: Copy) {
	dependsOn asciidoctor            // (8-1)
	from file("${asciidoctor.outputDir}")   // (8-2)
	into file("src/main/resources/static/docs")   // (8-3)
}

build {
	dependsOn copyDocument  // (9)
}

// (10)
bootJar {
	dependsOn copyDocument    // (10-1)
	from ("${asciidoctor.outputDir}") {  // (10-2)
		into 'static/docs'     // (10-3)
	}
}
  • (1) .adoc 파일 확장자를 가지는 AsciiDoc 문서를 생성해주는 Asciidoctor를 사용하기 위한 플러그인을 추가
  • (2) API 문서 스니핏이 생성될 경로를 지정
  • (3) AsciiDoctor에서 사용되는 의존 그룹을 지정하고 있습니다. :asciidoctor task가 실행되면 내부적으로 (3)에서 지정한 ‘asciidoctorExtensions’라는 그룹을 지정합니다.
  • (4) 'org.springframework.restdocs:spring-restdocs-mockmvc'를 추가함으로써 spring-restdocs-core와 spring-restdocs-mockmvc 의존 라이브러리가 추가됩니다.
  • (5) spring-restdocs-asciidoctor 의존 라이브러리를 추가합니다. (3)에서 지정한 asciidoctorExtensions 그룹에 의존 라이브러리가 포함이 됩니다.
  • (6) test task 실행 시, API 문서 생성 스니핏 디렉토리 경로를 설정합니다.
  • (7) asciidoctor task 실행 시, Asciidoctor 기능을 사용하기 위해 :asciidoctor task에 asciidoctorExtensions 을 설정합니다.
  • (8) build task 실행 전에 실행되는 task입니다. copyDocument task가 수행되면 index.html 파일이 src/main/resources/static/docs 에 copy 되며, copy된 index.html 파일은 API 문서를 파일 형태로 외부에 제공하기 위한 용도로 사용할 수 있습니다.
    • (8-1)에서는 :asciidoctor task가 실행된 후에 task가 실행 되도록 의존성을 설정합니다.
    • (8-2)에서는 "build/docs/asciidoc/" 경로에 생성되는 index.html을 copy한 후,
    • (8-3)의 "src/main/resources/static/docs" 경로로 index.html을 추가해 줍니다.
  • (9) build task가 실행되기 전에 :copyDocument task가 먼저 수행 되도록 합니다.
  • (10) 애플리케이션 실행 파일이 생성하는 :bootJar task 설정입니다.
    • (10-1)에서는 :bootJar task 실행 전에 :copyDocument task가 실행 되도록 의존성을 설정합니다.
    • (10-2)에서는 Asciidoctor 실행으로 생성되는 index.html 파일을 jar 파일 안에 추가해 줍니다. jar 파일에 index.html을 추가해 줌으로써 웹 브라우저에서 접속(http://localhost:8080/docs/index.html) 후, API 문서를 확인할 수 있습니다.
  • Gradle 기반 프로젝트에서는 src/docs/asciidoc/ 디렉토리를 생성해주어야 합니다.
  • 또한 해당 경로에 비어있는 index.adoc 템플릿 문서를 생성해주어야 API 문서화가 정상적으로 작동됩니다.

- Bz_Controller

@RestController
@RequiredArgsConstructor
public class Bz_Controller {

    private final Bz_Service bz_service;

    // 전체 조회
    @GetMapping("/search")
    public ResponseEntity All_Search() {
        List<Member_Dto> list = bz_service.find_All();

        return new ResponseEntity<>(new Multi_ResponseDto<>(list),HttpStatus.OK);
    }
}

- 테스트 케이스 작성

import api.v1.Bz_Controller;
import api.v1.Bz_Service;
import api.v1.Member_Dto;
import com.google.gson.Gson;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import java.util.List;

import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.requestParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static api.v1.util.ApiDocumentUtils.*;

@WebMvcTest(Bz_Controller.class)
@AutoConfigureRestDocs
public class Bz_Controller_Test {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private Gson gson;

    @MockBean
    private Bz_Service bz_service;

    @Test
    public void All_Search_Test() throws Exception {
        // request - 필요X
        // response

        Member_Dto response1 = Member_Dto.builder()
                .name("홍길동1")
                .sex("m")
                .company_name("나라사랑")
                .company_type("005")
                .company_location("001")
                .build();

        Member_Dto response2 = Member_Dto.builder()
                .name("김민영2")
                .sex("w")
                .company_name("우리나라")
                .company_type("003")
                .company_location("002")
                .build();

        List<Member_Dto> list = List.of(response2, response1);
        // Stubbing
        given(bz_service.find_All()).willReturn(list);
        // when
        ResultActions actions = mockMvc.perform(
                get("/search")
                        .accept(MediaType.APPLICATION_JSON));
        // then
        actions.andExpect(status().isOk())
//        ---------- API 문서화 작성 시작 --------------
                .andDo(document(		//
                        "get-All-Members",
                        getRequestPreProcessor(),
                        getResponsePreProcessor(),
                        responseFields(
                                List.of(
                                        fieldWithPath("list").type(JsonFieldType.ARRAY).description("회원 전체 리스트"),
                                        fieldWithPath("list[].name").type(JsonFieldType.STRING).description("회원 이름"),
                                        fieldWithPath("list[].sex").type(JsonFieldType.STRING).description("회원 성별"),
                                        fieldWithPath("list[].company_name").type(JsonFieldType.STRING).description("회사 이름"),
                                        fieldWithPath("list[].company_type").type(JsonFieldType.STRING).description("회사 업종코드"),
                                        fieldWithPath("list[].company_location").type(JsonFieldType.STRING).description("회사 위치코드")
                                )
                        )
                ));
//         ---------- API 문서화 작성 끝 --------------
    }
}

- ApiDocumentUtils

public interface ApiDocumentUtils {

    static OperationRequestPreprocessor getRequestPreProcessor() {
        return preprocessRequest(prettyPrint());
    }

    static OperationResponsePreprocessor getResponsePreProcessor() {
        return preprocessResponse(prettyPrint());
    }
}
  • @webMvcTest : 인자로 주어진 Controller만 지정하여 스캔하여 빈을 Load한다. @SpringBootTest 처럼 모든 빈을 로드하지 않고, 지정한 컴포넌트의 빈만 스캔하여 로드하기 때문에, Controller 레이어만 슬라이스 테스트 진행 시, 빠르고 효율적으로 진행할 수 있다. Service, Repository 객체의 사용이 필요할 경우 @MockBean으로 주입받아 테스트 진행하면된다.
  • @AutoConfigureRestDocs : Spring Rest docs의 자동 구성을 도와 준다. 이 어노테이션이 테스트 클래스에 선언되어야 API 문서화가 자동으로 진행된다.
  • 주석으로 구분한 API 문서화 코드 시작 전에는 Mocking을 통해 Controller만 잘라내어 테스트를 진행하고, API 문서화에 대한 설명이 목적이므로 검증코드는 최대한 간단하게 작성하였다.
  • andDo() : 테스트의 실행결과를 가지고 후처리할 수 있는 ResultHandler를 지정하며, log를 찍거나 실행결과를 print 시키는 등을 할 수 있다. 여기서 document() 메서드는 RestDocumentationResultHandler 내부에 존재하면서 이 클래스를 그대로 반환한며 이는 ResultHandler를 상속 받고 있다.
  • document() : 1번째 파라미터는 API 문서 스니핏의 식별자 역할을 한다. 즉 여기서는 "get-All-Members"로 정했으므로 문서 스니핏이 get-All-Members 디렉토리 하위에 생성된다. 2번째, 3번째 파라미터는 request, response 문서 영역 전처리로 각각 request body와 responsebody를 예쁘게 디자인해준다.
  • responseFields() : 문서로 표현될 response body를 의미. 파라미터로 List가 전달되는데 List의 원소로는 fieldWithPath()의 반환 값인 FieldDescriptor로 채워진다. FieldDescriptor 객체는 response body에 포함된 데이터를 표현한다. 체인형식으로 연쇄되어 메서드를 사용할 수 있다. 이는 requestFields() 메서드에도 request body에 대한 내용으로 똑같이 적용된다.
    • fieldWithPath()는 어떤 키 값의 Json 데이터를 가져와 표현할 것인지 지정한다.
    • type()은 Json 데이터의 property 값이 어떤 타입인지를 지정하여 문서에 표현한다.
    • descrption()은 해당 데이터가 어떤데이터인지에 대한 설명을 문서에 표현한다.

- test task 실행 -> 문서 스니핏 생성

  • 위 처럼 우리가 default로 설정해놓은 스니펫 생성 디렉토리 하위에서, 다시 document()에서 지정한 디렉토리 하위에 스니펫(adoc 확장자 파일)들이 생성되었다.

- 스니펫들 중 http-response.adoc 파일의 모습과 문서로 렌더링 된 모습

- 스니펫들을 모아 API 문서화하기

  • Gradle 프로젝트에서는 html로 렌더링하기위한 adoc 파일의 default 경로가 위 경로이다. 위 경로에 index.adoc 파일을 생성한다.

- index.adoc에 스니펫을 이용해 문서 작성하기

= 전국 사업자 연합 커뮤니티 애플리케이션	// (1)
:sectnums:	// (2)
:toc: left	// (3)
:toclevels: 4	// (4)
:toc-title: Table of Contents	// (5)
:source-highlighter: prettify	// (6)

Park Seong Jae <mirea720@gmail.com>

v1.0.0, 2022.08.17

***	// (7)

API 문서 개요
	// (8)
    이 문서는 전국 사업자 연합 커뮤니티 애플리케이션의 설명을 위한 API 문서입니다.
    필요한 Request 데이터와 Response 응답 데이터 등, API의 기본적인 내용을 담았습니다.
    추가적으로 문서 상 정보가 필요할 경우 위 메일로 전달 부탁드리겠습니다.

***
== Bz_Controller
=== 전체 회원 조회
.curl-request	// (9)
include::{snippets}/get-All-Members/curl-request.adoc[]	// (10)

.http-request
include::{snippets}/get-All-Members/http-request.adoc[]

.http-response
include::{snippets}/get-All-Members/http-response.adoc[]

.response-fields
include::{snippets}/get-All-Members/response-fields.adoc[]

***
  • (1) 문서의 제목을 작성하기 위해서는 =를 추가하면 됩니다. ====와 같이 =의 개수가 늘어날 수록 글자는 작아집니다.
  • (2) 목차에서 각 섹션에 넘버링을 해주기 위해서는 :sectnums: 를 추가하면 됩니다.
  • (3) :toc: 는 목차를 문서의 어느 위치에 구성할 것인지를 설정합니다. 여기서는 문서의 왼쪽에 목차가 표시되도록 left를 지정했습니다.
  • (4) :toclevels: 은 목차에 표시할 제목의 level을 지정합니다. 여기서는 4로 지정했기 때문에 ==== 까지의 제목만 목차에 표시됩니다.
  • (5) :toc-title: 은 목차의 제목을 지정할 수 있습니다.
  • (6) :source-highlighter: 소스 코드의 강조표시의 방법을 지정합니다. 여기서는 prettify를 지정했습니다.
  • (7) : *** 는 단락을 구분 짓는 수평선을 추가합니다.
  • (8) : 한 문단의 제목 다음 한 라인을 띄운 후, 다음 줄에서 한 칸 들여쓰기 문단을 작성 시, 박스 문단이 사용 가능합니다.
  • CAUTION: 을 사용해 경고문구를 추가할 수 있습니다. 이 외에도 NOTE: , TIP: 등이 있습니다.
  • http , https, fps, irc, mailto, hgd@gmail.com  등의 URL Scheme는 Asciidoc에서 자동으로 인식해 링크로 설정됩니다.
  • 이미지 추가 : 이미지를 추가하고 싶다면 image:: 를 사용합니다.
    ex) image::https://spring.io/images/spring-logo-9146a4d3298760c2e7e49595184e1975.svg[spring]
  • (9) :  . 은 하나의 스니핏 섹션 제목을 표현합니다. curl-request는 섹션의 제목이며 원하는대로 수정 가능합니다.
  • (10) : include는 Asciidoctor에서 사용하는 매크로 중 하나이며, 스니핏을 템플릿 문서에 포함할 때 사용합니다.
    {snippets}는 해당 스니핏이 생성되는 디폴트 경로이며, 이것은 build.gradle에서 설정한 snippetsDir 변수를 참조합니다.
    -> 여기서는 build/generated-snippets 입니다.

- 템플릿 문서 -> HTML 파일로 변환하기

  • Gradle 탭에서 bootJar 또는 build  task를 더블클릭하여 명령을 실행합니다.

  • 위처럼 html파일이 잘 생성되었습니다.

  • 애플리케이션 서버 실행 후, url로도 잘 확인됩니다.
Comments