[API 문서화]
클라이언트가 REST API 백엔드 애플리케이션에 요청을 전송하기 위해서
알아야 할 요청 정보 (URI, request body, query parameter)를 문서로 정리하는 것.
개발자가 요청 URI 등의 API 정보를 직접 작성할 수도 있고, 애플리케이션 빌드를 통해 자동생성도 가능.
[Spring Rest Docs]
REST API문서를 자동으로 생성해주는 Spring 하위 프로젝트.
Controller의 슬라이스 테스트가 통과 되어야지만 API문서가 정상적으로 만들어진다.
[Spring Rest Docs의 API 문서 생성 흐름]
1. 테스트 코드 작성
A. Controller의 슬라이스 테스트 코드 작성.
B. API 스팩 정보 코드 작성
Controller에 정의되어 있는 API 스팩 정보(Request body, Response Body, Query Parameter) 코드로 작성
2. test 태스크 실행
A. 작성된 슬라이스 테스트 코드를 실행
하나의 테스트 클래스 실행도 되지만, 일반적으로 Gradle의 빌드 task 중 하나인
test task를 실행시켜 API 문서 스니핏을 일괄 생성.
B. 테스트 실행 결과가 passed 가 되도록 수정을 반복.
3. API 문서 스니핏(.adoc 파일) 생성
테스트 케이스의 결과가 passed이면 테스트 코드에 포함된 API 스펙 정보 코드를 기반으로 API 문서 스니핏이 .adoc 확장자를 가진 파일로 생성된다.
4. API 문서 생성
생성된 API 문서 스니핏을 모아서 하나의 API 문서로 생성.
5. API 문서를 HTML로 변환
생성된 API 문서를 HTML 파일로 변환하고 이 API 문서는 URL을 통해 해당 HTML에 접속할 수 도 있다.
[Controller 테스트 케이스에 Spring RestDocs 적용하기]
[ postMember() ]
import com.codestates.member.controller.MemberController;
import com.codestates.member.dto.MemberDto;
import com.codestates.member.entity.Member;
import com.codestates.member.mapper.MemberMapper;
import com.codestates.member.service.MemberService;
import com.codestates.stamp.Stamp;
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.HttpHeaders;
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 java.util.List;
import static com.codestates.util.ApiDocumentUtils.getRequestPreProcessor;
import static com.codestates.util.ApiDocumentUtils.getResponsePreProcessor;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(MemberController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureRestDocs
public class MemberControllerRestDocsTest {
@Autowired
private MockMvc mockMvc;
// (1)
@MockBean
private MemberService memberService;
// (2)
@MockBean
private MemberMapper mapper;
@Autowired
private Gson gson;
@Test
public void postMemberTest() throws Exception {
// (3) given
MemberDto.Post post =
new MemberDto.Post("hgd@gmail.com", "홍길동", "010-1234-5678");
String content = gson.toJson(post);
// (4)
given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class)))
.willReturn(new Member());
// (5)
Member mockResultMember = new Member();
mockResultMember.setMemberId(1L);
given(memberService.createMember(Mockito.any(Member.class)))
.willReturn(mockResultMember);
// (6) when
ResultActions actions =
mockMvc.perform(
post("/v11/members")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(content)
);
// then
actions
.andExpect(status().isCreated())
.andExpect(header().string("Location", is(startsWith("/v11/members/"))))
.andDo(document( // (7)
"post-member", // (7-1)
getRequestPreProcessor(), // (7-2)
getResponsePreProcessor(), // (7-3)
requestFields( // (7-4)
List.of(
fieldWithPath("email")
.type(JsonFieldType.STRING).description("이메일"), // (7-5)
fieldWithPath("name")
.type(JsonFieldType.STRING).description("이름"),
fieldWithPath("phone")
.type(JsonFieldType.STRING).description("휴대폰 번호")
)
),
responseHeaders( // (7-6)
headerWithName(HttpHeaders.LOCATION)
.description("Location header. 등록된 리소스의 URI")
)
));
}
}
(1), (2) : MemberService와 MemberMapper의 Mock Bean을 주입 받는다.
(3) : postMember()에 전송하는 request body
(4) : mapper를 사용하여 memberPostToMember 메서드의 파라미터 객체 타입인 MemberDto.Post 넣음.
(5) : Member 객체를 생성하고 식별자 memberId를 할당해줌.
그리고 mock-memberService 클래스의 createMember()메서드를 사용하여 실제 객체 Member.class 넣음.
.willReturn() 안에는 createMember() 메서드의 반환객체인 member 타입의 객체를 넣어줌.
(6) : 응답 데이터 생성 후 전송 시작함과 HTTP Method와 URL 설정 및 요청/응답 데이터 형식 지정.
(7) : .andDo(document())
API 스펙 정보(request, response)를 전달 받아 문서화 작업을 수행하는 핵심 메서드.
(7-1) : document()의 첫 파라미터, post-member API 문서 스니핏의 식별자 역할이다.
post-member은 post-member 디렉토리 하위에 생성을 의미
(7-2), (7-3) : 문서 스니핏을 생성하기 전에 request와 response에 해당하는 문서 영역을 전처리하는 역할.
(7-4) requestFields() : 문서로 표현될 request body를 의미하며, 파라미터로 전달되는
List<FieldDescriptor>의 원소인 FieldDescriptor 객체가 request body에 포함된 데이터를 표현한다.
(7-5) request body를 JSON 포맷으로 표현 했을 때, 하나의 프로퍼티를 의미하는 FieldDescriptor.
type(JsonFieldType.STRING)은 JSON 프로퍼티의 값이 문자열임을 나타낸다.
(7-6) responseHeaders()는 response header를 의미하며
파라미터의 HeaderDescriptor 객체가 response header를 표현.
HttpHeaders.LOCATION : HTTP response의 Location header를 의미.
위의 테스트가 모두 passed 결과가 나온다면 (7)에서 작성했던 post-member 디렉토리 하위에 생성.
[ patchMember() ]
import com.codestates.member.controller.MemberController;
import com.codestates.member.dto.MemberDto;
import com.codestates.member.entity.Member;
import com.codestates.member.mapper.MemberMapper;
import com.codestates.member.service.MemberService;
import com.codestates.stamp.Stamp;
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 java.util.List;
import static com.codestates.util.ApiDocumentUtils.getRequestPreProcessor;
import static com.codestates.util.ApiDocumentUtils.getResponsePreProcessor;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@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 patchMemberTest() throws Exception {
// given
long memberId = 1L;
MemberDto.Patch patch =
new MemberDto.Patch(memberId, "홍길동", "010-1111-1111",Member.MemberStatus.MEMBER_ACTIVE);
String content = gson.toJson(patch);
MemberDto.Response responseDto =
new MemberDto.response(1L,
"hgd@gmail.com",
"홍길동",
"010-1111-1111",
Member.MemberStatus.MEMBER_ACTIVE,
new Stamp());
given(mapper.memberPatchToMember(Mockito.any(MemberDto.Patch.class)))
.willReturn(new Member());
given(memberService.updateMember(Mockito.any(Member.class)))
.willReturn(new Member());
given(mapper.memberToMemberResponse(Mockito.any(Member.class)))
.willReturn(responseDto);
// when
ResultActions actions =
mockMvc.perform(
patch("/v11/members/{member-id}", memberId)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(content)
);
// then
actions
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.memberId").value(patch.getMemberId()))
.andExpect(jsonPath("$.data.name").value(patch.getName()))
.andExpect(jsonPath("$.data.phone").value(patch.getPhone()))
.andExpect(jsonPath("$.data.memberStatus").value(patch.getMemberStatus().getStatus()))
.andDo(document("patch-member",
getRequestPreProcessor(),
getResponsePreProcessor(),
pathParameters( // (1)
parameterWithName("member-id").description("회원 식별자")
),
requestFields(
List.of(
fieldWithPath("memberId").type(JsonFieldType.NUMBER).description("회원 식별자").ignored(), // (2)
fieldWithPath("name").type(JsonFieldType.STRING).description("이름").optional(), // (3)
fieldWithPath("phone").type(JsonFieldType.STRING).description("휴대폰 번호").optional(),
fieldWithPath("memberStatus").type(JsonFieldType.STRING).description("회원 상태: MEMBER_ACTIVE / MEMBER_SLEEP / MEMBER_QUIT").optional()
)
),
responseFields( // (4)
List.of(
fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
fieldWithPath("data.memberId").type(JsonFieldType.NUMBER).description("회원 식별자"), // (5)
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("스탬프 갯수")
)
)
));
}
}
(1) API 스펙 정보 중에 URL의 path variable의 정보를 추가.
MemberController의 patchMember()와 getMember()는
v11/members/{member-id}와 같은 요청 URL에 path variable이 있다.
(2) memberId의 경우 path variable 정보로 전달 받기 때문에
MemberDto.Patch DTO 클래스에서 request body에 매핑되지 않는 정보이므로 .ignore()로 제외.
(3) 회원 정보 수정은 원하는 만큼 수정해야하므로 .optional() 을 추가하여 선택적으로 설정
(4) responseFields()는 문서로 표현될 response body를 의미하며, 파라미터로 전달되는 List<FieldDescriptor>의 원소인 FieldDescriptor 객체가 response body에 포함된 데이터를 표현.
JsonFieldType.OBJECT : JSON 포맷으로 표현된 프로퍼티의 값이 객체임을 의미
JsonFieldType.NUMBER : JSON 포맷으로 표현된 프로퍼티의 값이 int나 long 같은 NUMBER 의미.
(5) fieldWithPath("data.memberId")의 data.memberId는 data 프로퍼티의 하위 프로퍼티를 의미.
{
"data": {
"memberId": 1, // data.memberId
"email": "hgd@gmail.com",
"name": "홍길동1",
"phone": "010-1111-1111",
"memberStatus": "활동중",
"stamp": 0
}
}
[핵심 포인트]
1.
@SpringBootTest는 DB까지 요청 프로세스가 이어지는 통합 테스트용.
@WebMvcTest는 Controller를 위한 슬라이스 테스트 전용.
2. document() 메서드는 API 스펙 정보(request body, response body)를 전달받아
실질적인 문서화 작업을 하는 핵심 메서드
3. OperationRequestPreprocessor, OperationResponsePreprocessor를 이용하여
API 문서를 생성하기 전에 전처리를 수행할 수 있다.
4. requestFields()는 문서로 표현될 request body를 의미. 파라미터로 전달되는
List<FieldDescriptor>의 원소인 FieldDescriptor 객체가 request body 데이터를 표현.
5. responseFields()는 문서로 표현될 response body를 의미. 파라미터로 전달되는
List<FieldDescriptor>의 원소인 FieldDescriptor 객체가 response body 데이터를 표현.
[스니핏을 이용한 API 문서화]
'백엔드 학습 과정 > Section 3 [Spring MVC, JDBC, JPA, RestDo' 카테고리의 다른 글
Section3. 회고 (0) | 2023.01.14 |
---|---|
#10. 애플리케이션 빌드 (0) | 2023.01.14 |
#8. Spring MVC 테스팅 (0) | 2023.01.11 |
#7. Spring MVC 트랜잭션 (0) | 2023.01.11 |
#6. Spring Data JPA (0) | 2023.01.11 |