[Spring MVC] API 문서화 실습
클라이언트가 REST API 백엔드 애플리케이션에 요청을 전송하기 위해서 알아야 되는 요청 정보를 문서로 잘 정리하는 것
build.gradle 설정
Spring Rest Docs를 사용해서 API 문서를 생성하기 위해서는 .adoc 문서 스니핏을 생성해주는 Asciidoctor가 필요하다.
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" // .adoc 파일 확장자를 가지는 AsciiDoc 문서를 생성해주는 Asiidoctor를 사용하기 위한 플러그인
id 'java'
}
group = 'com.codestates'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
repositories {
mavenCentral()
}
// ext 변수의 set() 메서드를 이용해서 API 문서 스니핏이 생성될 경로를 지정
ext {
set('snippetsDir', file("build/generated-snippets"))
}
// AsciiDoctor에서 사용되는 의존 그룹을 지정함
configurations {
asciidoctorExtensions
}
dependencies {
// spring-restdocs-core, spring-restdocs-mockmvc 의존 라이브러리 추가됨
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
// spring-restdocs-asciidoctor 의존 라이브러리 추가. asciidoctorExtensions 그룹에 포함됨
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'
}
// :test task 실행 시, API문서 생성 스니핏 디렉토리 경로를 설정
tasks.named('test') {
outputs.dir snippetsDir
useJUnitPlatform()
}
// :asciidoctor task 실행 시, Asciidoctor 기능을 사용하기 위해 :asciidoctor task에 asciidoctorExtensions을 설정
tasks.named('asciidoctor') {
configurations "asciidoctorExtensions"
inputs.dir snippetsDir
dependsOn test
}
// :build task 실행 전에 실행되는 task. :copyDocument task가 수행되면 index.html 파일이
// src/main/resources/static/docs에 copy 되며, copy된 html파일은 API문서를 파일 형태로 외부에 제공하기 위한 용도로 사용됨 (1)
task copyDocument(type: Copy) {
dependsOn asciidoctor // :asciidoctor task가 실행된 후에 task가 실행 되도록 의존성을 설정
from file("${asciidoctor.outputDir}") // build/docs/asciidoc/ 경로에 index.html을 추가해 줌
into file("src/main/resources/static/docs") // src/main/resources/static/docs 경로로 index.html을 추가해 줌
}
build {
dependsOn copyDocument // :build task가 실행되기 전에 :copyDocument task가 실행 되도록 의존성을 설정한다.
}
// 애플리케이션 실행 파일이 생성하는 :bootJar task 설정 (2)
bootJar {
dependsOn copyDocument // :bootJar task 실행 전에 :copyDocument task가 실행 되도록 의존성을 설정
from ("${asciidoctor.outputDir}") { // Asciidoctor 실행으로 생성되는 index.html 파일을 jar 파일 안에 추가해 줌
into 'static/docs' // jar 파일에 index.html을 추가해 줌으로써 웹 브라우저에서 접속 후, API 문서를 확인할 수 있음
}
}
(1)에서 copy 되는 index.html은 외부에 제공하기 위한 용도, (2)에서는 index.html을 애플리케이션 실행 파일인 jar 파일에 포함해서 웹 브라우저에서 API 문서를 확인하기 위한 용도이다.
build.gradle 설정 다음으로 Gradle 기반 프로젝트에서는 src/docs/asciidoc/index.adco 를 생성한다.
MemberController 클래스에 대한 API 문서 생성용 테스트 케이스 작성
import com.codestates.member.controller.MemberController;
import com.codestates.member.mapper.MemberMapper;
import com.codestates.member.service.MemberService;
import com.google.gson.Gson;
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.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(MemberController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureRestDocs
public class MemberControllerDocumentationTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private MemberService memberService;
@MockBean
private MemberMapper mapper;
@Autowired
private Gson gson;
@Test
public void getMemberTest() throws Exception {
// TODO 여기에 MemberController의 getMember() 핸들러 메서드 API 스펙 정보를 포함하는 테스트 케이스를 작성
}
@Test
public void getMembersTest() throws Exception {
// TODO 여기에 MemberController의 getMembers() 핸들러 메서드 API 스펙 정보를 포함하는 테스트 케이스를 작성
}
@Test
public void deleteMemberTest() throws Exception {
// TODO 여기에 MemberController의 deleteMember() 핸들러 메서드 API 스펙 정보를 포함하는 테스트 케이스를 작성
}
}
getMemberTest()
@Test
public void getMemberTest() throws Exception {
Member member = new Member("abc@gmail.com","Demuu","010-1111-1111");
member.setMemberId(1L);
member.setMemberStatus(Member.MemberStatus.MEMBER_ACTIVE);
long memberId = 1L;
MemberDto.Response memberResponse = new MemberDto.Response(
1L, "abc@gmail.com","Demuu","010-1111-1111", Member.MemberStatus.MEMBER_ACTIVE, new Stamp());
given(memberService.findMember(Mockito.anyLong())).willReturn(member);
given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(memberResponse);
mockMvc.perform(get("/v11/members/{member-id}", memberId))
.andExpectAll(
status().isOk(),
jsonPath("$.data.memberId").value(member.getMemberId()),
jsonPath("$.data.email").value(member.getEmail()),
jsonPath("$.data.name").value(member.getName()),
jsonPath("$.data.phone").value(member.getPhone()),
jsonPath("$.data.memberStatus").value(member.getMemberStatus().getStatus())
) .andDo(document(
"get-member",
getRequestPreProcessor(),
getResponsePreProcessor(),
pathParameters(
parameterWithName("member-id").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("스탬프")
))));
}
getMembersTest()
@Test
public void getMembersTest() throws Exception {
List<Member> members = new ArrayList<>();
members.add(new Member());
members.add(new Member());
members.add(new Member());
members.add(new Member());
members.add(new Member());
members.add(new Member());
MultiValueMap<String, String> info = new LinkedMultiValueMap<>();
int page = 2; int size = 3;
info.add("page", String.valueOf(page));
info.add("size", String.valueOf(size));
PageRequest pageRequest = PageRequest.of(page - 1, size);
int start = size * (page - 1);
int end = size * (page - 1) + size;
Page<Member> pageMembers = new PageImpl<>(members.subList(start,end), pageRequest, members.size());
List<MemberDto.Response> pageMemberResponseDto = new ArrayList<>();
for(int i = 1; i <= pageMembers.getContent().size(); i++){
pageMemberResponseDto.add(new MemberDto.Response(
i, "abc"+i+"@gmail.com","Demuu"+i,"010-1111-111"+i, Member.MemberStatus.MEMBER_ACTIVE,new Stamp()));
}
given(memberService.findMembers(Mockito.anyInt(), Mockito.anyInt())).willReturn(pageMembers);
given(mapper.membersToMemberResponses(Mockito.anyList())).willReturn(pageMemberResponseDto);
mockMvc.perform(
get("/v11/members")
.params(info)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andExpect(jsonPath("$.data.size()").value(size))
.andDo(document(
"get-members",
getRequestPreProcessor(),
getResponsePreProcessor(),
requestParameters(List.of(
parameterWithName("page").description("페이지 번호"),
parameterWithName("size").description("페이지 크기")
))
));
}
deleteMemberTest()
@Test
public void deleteMemberTest() throws Exception {
long memberId = 1L;
doNothing().when(memberService).deleteMember(Mockito.anyLong());
mockMvc.perform(
delete("/v11/members/{member-id}", memberId)
.accept(MediaType.APPLICATION_JSON)
) .andExpect(status().isNoContent())
.andDo(document(
"delete-member",
getRequestPreProcessor(),
getResponsePreProcessor(),
pathParameters(
parameterWithName("member-id").description("회원 식별자")
)
));
}
docs/asciidoc/index.adoc
=== 특정 회원 찾기
.curl-request
include::{snippets}/get-member/curl-request.adoc[]
.http-request
include::{snippets}/get-member/http-request.adoc[]
.http-response
include::{snippets}/get-member/http-response.adoc[]
.httpie-request
include::{snippets}/get-member/httpie-request.adoc[]
.path=parameters
include::{snippets}/get-member/path-parameters.adoc[]
.request-body
include::{snippets}/get-member/request-body.adoc[]
.response-body
include::{snippets}/get-member/response-fields.adoc[]
.response-fields
include::{snippets}/get-member/response-fields.adoc[]
=== 모든 회원 찾기
.curl-request
include::{snippets}/get-members/curl-request.adoc[]
.http-request
include::{snippets}/get-members/http-request.adoc[]
.http-response
include::{snippets}/get-members/http-response.adoc[]
.httpie-request
include::{snippets}/get-members/httpie-request.adoc[]
.request-body
include::{snippets}/get-members/request-body.adoc[]
.request-fields
include::{snippets}/get-members/request-fields.adoc[]
.request-parameters
include::{snippets}/get-members/request-parameters.adoc[]
.response-body
include::{snippets}/get-members/response-body.adoc[]
=== 특정 회원 삭제
.curl-request
include::{snippets}/delete-member/curl-request.adoc[]
.http-request
include::{snippets}/delete-member/http-request.adoc[]
.http-response
include::{snippets}/delete-member/http-response.adoc[]
.httpie-request
include::{snippets}/delete-member/httpie-request.adoc[]
.path-parameters
include::{snippets}/delete-member/path-parameters.adoc[]
.request-body
include::{snippets}/delete-member/http-response.adoc[]
.response-body
include::{snippets}/delete-member/http-response.adoc[]
.response-fields
include::{snippets}/delete-member/http-response.adoc[]
추가 학습
Asciidoc란?
Spring Rest Docs를 통해 생성되는 텍스트 기반 문서 포맷.
Asciidoc은 주로 기술 문서 작성을 위해 설계된 가벼운 마크업 언어이다.
Asciidoc 포맷을 사용해서 메모, 문서, 기사, 서적, E-Book, 웹 페이지, 매뉴얼 페이지, 블로그 게시물 등을 작성할 수 있으며
Asciidoc 포맷으로 작성된 문서는 HTML, PDF, EPUB, 매뉴얼 페이지를 포함한 다양한 형식으로 변환될 수 있다.
Asiidoctor란?
AsciiDoc 포맷의 문서를 파싱해서 HTML5, 메뉴얼 페이지, PDF 및 EPUB3 등의 문서를 생성하는 툴.
댓글남기기