애플리케이션이나 프로그램, 시스템을 개발할 때 한가지 분야의 소수 개발자만 참여한다면 그때 그때 직접적으로 논의하고 수정해 나갈 수 있지만, 개발의 범위가 커지고 인원이 많아진다면 필요할 때 마다 논의하는 것이 힘들어 질 것이고, 미리 정해놓은 약속이 필요할 것입니다. 그 약속을 정의해 놓은 문서가 API 문서
입니다.
보통 프론트엔드 - 백엔드 간의 협업 시 주고 받을 데이터나 메서드를 개발 전 사전에 정의하여 혼선을 생기는 상황을 방지하는데에도 사용됩니다.
API문서 작성은 여러방법으로 작성할 수도 있습니다.gitbook
, postman
처럼 개발자가 타자를 직접 치는 방법도 있고, 손으로 직접 그리면서도 할 수 있고, Rest API Docs
와 같이 테스트 통과시에만 자동화로 API문서를 작성할 수도 있습니다.
그 중에서도 저는 Rest API Docs
를 활용하여 API문서를 작성하였습니다.
Rest API Docs
에 대한 자세한 설명 : https://velog.io/@ds02168/55일차Swagger-Spring-Rest-Docs
아래의 소스코드도 위의 내용을 토대로 작성하였습니다.
build.gradle
application.yml
등의 설정은 위의 설명과 동일하게 수행하였습니다.
또 Service
, Repository
, Mapper
는 실제 서비스 구현이 목적이 아니라 Mocking
객체로 대체하여 테스트 하므로 로직 구현은 필요하지 않습니다.(테스트에 필요한 메서드 선언만)
@Slf4j
@RestController
@Validated
@RequestMapping("v1/members")
public class MemberController {
private final MemberService memberService;
private final MemberMapper mapper;
public MemberController(MemberService memberService, MemberMapper mapper){
this.memberService = memberService;
this.mapper = mapper;
}
@PostMapping
public ResponseEntity postMember(@Valid @RequestBody MemberDto.Post requestBody){
Member member = mapper.memberPostToMember(requestBody);
Member createdMember = memberService.createMember(member);
MemberDto.response response = mapper.memberToMemberResponse(createdMember);
return new ResponseEntity<>(
new SingleResponseDto(response),
HttpStatus.CREATED);
}
@PatchMapping("/{member-id}")
public ResponseEntity patchMember(@PathVariable("member-id") @Positive long memberId,
@Valid @RequestBody MemberDto.Patch requestBody){
requestBody.setMemberId(memberId);
Member member = memberService.updateMember(mapper.memberPatchToMember(requestBody));
MemberDto.response response = mapper.memberToMemberResponse(member);
return new ResponseEntity<>(
new SingleResponseDto<>(response),HttpStatus.OK
);
}
@GetMapping("/{member-id}")
public ResponseEntity getMember(@PathVariable("member-id") @Positive long memberId){
Member member = memberService.findMember(memberId);
MemberDto.response response = mapper.memberToMemberResponse(member);
return new ResponseEntity<>(
new SingleResponseDto<>(response),
HttpStatus.OK
);
}
@GetMapping
public ResponseEntity getMembers(@Positive @RequestParam int page,
@Positive @RequestParam int size){
Page<Member> pageMembers = memberService.findMembers(page -1,size);
List<Member> members = pageMembers.getContent();
List<MemberDto.response> responses = mapper.membersToMemberResponses(members);
return new ResponseEntity<>(
new MultiResponseDto<>(responses,pageMembers),
HttpStatus.OK
);
}
@DeleteMapping("/{member-id}")
public ResponseEntity deleteMember(@PathVariable("member-id") @Positive long memberId){
memberService.deleteMember(memberId);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
}
기존의 컨틀롤러 역할과 동일하게 작성하였습니다. 실질적인 서비스 로직 처리는 수행하지 않고 클라이언트와 비즈니스 로직을 이어주는 역할을 수행합니다.
- 엔드포인트로 매개변수를 통해 입력받음
Service
,Mapper
의 메서드 호출ResponseEntity
객체로 감싸서 반환
핸들러 메서드마다 차이가 존재하므로 약간씩 다를 수 있으나 크게 위의 3단계를 충족 하도록 작성하였습니다.
@WebMvcTest(MemberController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureRestDocs
public class MemberControllerRestDocsTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private Gson gson;
@MockBean
private MemberService memberService;
@MockBean
private MemberMapper mapper;
@Test
public void postMemberTest() throws Exception {
//given
MemberDto.Post post = new MemberDto.Post("김코딩","s4goodbye!","m",
"코드스테이츠",5,1);
String content = gson.toJson(post);
MemberDto.response responseDto =
new MemberDto.response(1L,"김코딩","s4goodbye!","m",
"코드스테이츠",5,1);
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("/v1/members")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(content)
);
//then
actions
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.name").value(post.getName()))
.andExpect(jsonPath("$.data.sex").value(post.getSex()))
.andExpect(jsonPath("$.data.company_name").value(post.getCompany_name()))
.andDo(document(
"post-member",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
requestFields(
List.of(
fieldWithPath("name").type(JsonFieldType.STRING).description("이름"),
fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호"),
fieldWithPath("sex").type(JsonFieldType.STRING).description("성별"),
fieldWithPath("company_name").type(JsonFieldType.STRING).description("회사명"),
fieldWithPath("company_type").type(JsonFieldType.NUMBER).description("업종"),
fieldWithPath("company_location").type(JsonFieldType.NUMBER).description("지역")
)
),
responseFields(
List.of(
fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
fieldWithPath("data.memberId").type(JsonFieldType.NUMBER).description("회원 식별자"),
fieldWithPath("data.name").type(JsonFieldType.STRING).description("이름"),
fieldWithPath("data.password").type(JsonFieldType.STRING).description("비밀번호"),
fieldWithPath("data.sex").type(JsonFieldType.STRING).description("성별"),
fieldWithPath("data.company_name").type(JsonFieldType.STRING).description("회사명"),
fieldWithPath("data.company_type").type(JsonFieldType.NUMBER).description("업종"),
fieldWithPath("data.company_location").type(JsonFieldType.NUMBER).description("지역")
)
)
));
}
@Test
public void patchMemberTest() throws Exception{
//given
long memberId = 1L;
MemberDto.Patch patch = new MemberDto.Patch(memberId,"김코딩","m",
"코드스테이츠",5,1);
String content = gson.toJson(patch);
MemberDto.response responseDto =
new MemberDto.response(1L,"김코딩","s4goodbye!","m",
"코드스테이츠",5,1);
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("/v1/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.company_name").value(patch.getCompany_name()))
.andDo(document(
"patch-member",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
pathParameters(
parameterWithName("member-id").description("회원 식별자")
),
requestFields(
List.of(
fieldWithPath("memberId").type(JsonFieldType.NUMBER).description("회원 식별자").ignored(),
fieldWithPath("name").type(JsonFieldType.STRING).description("이름").optional(),
fieldWithPath("sex").type(JsonFieldType.STRING).description("성별").optional(),
fieldWithPath("company_name").type(JsonFieldType.STRING).description("회사명").optional(),
fieldWithPath("company_type").type(JsonFieldType.NUMBER).description("업종").optional(),
fieldWithPath("company_location").type(JsonFieldType.NUMBER).description("지역").optional()
)
),
responseFields(
List.of(
fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
fieldWithPath("data.memberId").type(JsonFieldType.NUMBER).description("회원 식별자"),
fieldWithPath("data.name").type(JsonFieldType.STRING).description("이름"),
fieldWithPath("data.password").type(JsonFieldType.STRING).description("비밀번호"),
fieldWithPath("data.sex").type(JsonFieldType.STRING).description("성별"),
fieldWithPath("data.company_name").type(JsonFieldType.STRING).description("회사명"),
fieldWithPath("data.company_type").type(JsonFieldType.NUMBER).description("업종"),
fieldWithPath("data.company_location").type(JsonFieldType.NUMBER).description("지역")
)
)
));
}
@Test
public void getMemberTest() throws Exception{
//given
long memberId = 1L;
MemberDto.response responseDto =
new MemberDto.response(1L,"김코딩","s4goodbye!","m",
"코드스테이츠",5,1);
given(memberService.findMember(Mockito.anyLong())).willReturn(new Member());
given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(responseDto);
//when
ResultActions actions =
mockMvc.perform(
get("/v1/members/{member-id}",memberId)
.accept(MediaType.APPLICATION_JSON)
);
//then
actions
.andExpect(status().isOk())
.andDo(document(
"get-member",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
pathParameters(
parameterWithName("member-id").description("회원 식별자")
),
responseFields(
List.of(
fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
fieldWithPath("data.memberId").type(JsonFieldType.NUMBER).description("회원 식별자"),
fieldWithPath("data.name").type(JsonFieldType.STRING).description("이름"),
fieldWithPath("data.password").type(JsonFieldType.STRING).description("비밀번호"),
fieldWithPath("data.sex").type(JsonFieldType.STRING).description("성별"),
fieldWithPath("data.company_name").type(JsonFieldType.STRING).description("회사명"),
fieldWithPath("data.company_type").type(JsonFieldType.NUMBER).description("업종"),
fieldWithPath("data.company_location").type(JsonFieldType.NUMBER).description("지역")
)
)
));
}
@Test
public void getMembersTest() throws Exception{
//given
Member member1 = new Member(1L,"김코딩","s4goodbye!","m",
"코드스테이츠",5,1);
Member member2 = new Member(2L,"박해커","1q2w3e4r!","m",
"유어클래스",4,2);
Member member3 = new Member(3L,"최개발","spring123@","w",
"스프링",3,6);
Page<Member> pageMembers = new PageImpl<>(List.of(member1,member2,member3), PageRequest.of(1,10, Sort.by("memberId").descending()),3);
List<MemberDto.response> responses = List.of(
new MemberDto.response(1L,"김코딩","s4goodbye!","m",
"코드스테이츠",5,1),
new MemberDto.response(2L,"박해커","1q2w3e4r!","m",
"유어클래스",4,2),
new MemberDto.response(3L,"최개발","spring123@","w",
"스프링",3,6)
);
given(memberService.findMembers(Mockito.anyInt(),Mockito.anyInt())).willReturn(new PageImpl<>(List.of()));
given(mapper.membersToMemberResponses(Mockito.anyList())).willReturn(responses);
String page = "1";
String size = "10";
MultiValueMap<String,String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("page",page);
queryParams.add("size",size);
//when
ResultActions actions =
mockMvc.perform(
get("/v1/members")
.accept(MediaType.APPLICATION_JSON)
.params(queryParams)
);
//then
actions
.andExpect(status().isOk())
.andDo(document(
"get-members",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
requestParameters(
parameterWithName("page").description("페이지"),
parameterWithName("size").description("페이지당 회원 수")
),
responseFields(
List.of(
fieldWithPath("data").type(JsonFieldType.ARRAY).description("결과 데이터"),
fieldWithPath("data[].memberId").type(JsonFieldType.NUMBER).description("회원 식별자"),
fieldWithPath("data[].name").type(JsonFieldType.STRING).description("이름"),
fieldWithPath("data[].password").type(JsonFieldType.STRING).description("비밀번호"),
fieldWithPath("data[].sex").type(JsonFieldType.STRING).description("성별"),
fieldWithPath("data[].company_name").type(JsonFieldType.STRING).description("회사명"),
fieldWithPath("data[].company_type").type(JsonFieldType.NUMBER).description("업종"),
fieldWithPath("data[].company_location").type(JsonFieldType.NUMBER).description("지역"),
fieldWithPath("pageInfo.page").type(JsonFieldType.NUMBER).description("현재 페이지"),
fieldWithPath("pageInfo.size").type(JsonFieldType.NUMBER).description("페이지 크기"),
fieldWithPath("pageInfo.totalElements").type(JsonFieldType.NUMBER).description("전체 회원 수"),
fieldWithPath("pageInfo.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수")
)
)
));
}
@Test
public void deleteMemberTest() throws Exception {
//given
long memberId = 1L;
doNothing().when(memberService).deleteMember(Mockito.anyLong());
//when
ResultActions actions =
mockMvc.perform(
delete("/v1/members/{member-id}",memberId)
.accept(MediaType.APPLICATION_JSON)
);
//then
actions
.andExpect(status().isNoContent())
.andDo(document(
"delete-member",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
pathParameters(
parameterWithName("member-id").description("회원 식별자")
)
));
}
}
Rest API Docs
는 큰 특징이 2가지 존재합니다.
스프링 컨테이너의 빈을 사용하지 않고 Mock
객체를 만들어 Mock
컨테이너에서 가짜 빈 객체를 주입받는 테스트 케이스를 그대로 활용할 수 있어, 실제 구현 전 설계 단계에서 작성 할 수 있습니다.
또, 기존의 테스트케이스 작성과 동일하고 마지막에 andDo()
메서드를 추가함으로 써 andExpect()
메서드 등의 테스트를 위한 메서드가 모두 통과하면 API 문서
를 작성합니다.
에너테이션
과 메서드
는 링크
의 게시물에 설명이 적혀 있습니다.
given
- when
- then
3단계를 나누어 수행 하라는 말씀이 머릿속에 생생하게 남아 이번에도 나누어서 진행하였습니다.
여러 목록을 한번에 지정한 갯수만큼 가져오는 page
는 기억이 가물 가물 했지만 이번에 다시 작성해 봄으로써 중요한걸 잊을 뻔 했다 싶었습니다.
= 전국 사업자 연합 애플리케이션
:sectnums:
:toc: left
:tocleves: 4
:toc-title: Table of Contents
:source-highlighter: prettify
Yoo Tae Hyong <1995musso@gmail.com>
v1.0.0, 2022,08.16
***
== MemberController
=== 회원 등록
.curl-request
include::{snippets}/post-member/curl-request.adoc[]
.http-request
include::{snippets}/post-member/http-request.adoc[]
.request-fields
include::{snippets}/post-member/request-fields.adoc[]
.http-response
include::{snippets}/post-member/http-response.adoc[]
.response-fields
include::{snippets}/post-member/response-fields.adoc[]
=== 회원 정보 수정
.curl-request
include::{snippets}/patch-member/curl-request.adoc[]
.http-request
include::{snippets}/patch-member/http-request.adoc[]
.request-fields
include::{snippets}/patch-member/request-fields.adoc[]
.path-parameters
include::{snippets}/patch-member/path-parameters.adoc[]
.http-response
include::{snippets}/patch-member/http-response.adoc[]
.response-fields
include::{snippets}/patch-member/response-fields.adoc[]
=== 회원 정보 검색
.curl-request
include::{snippets}/get-member/curl-request.adoc[]
.http-request
include::{snippets}/get-member/http-request.adoc[]
.path-parameters
include::{snippets}/get-member/path-parameters.adoc[]
.http-response
include::{snippets}/get-member/http-response.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[]
.request-parameters
include::{snippets}/get-members/request-parameters.adoc[]
.http-response
include::{snippets}/get-members/http-response.adoc[]
.response-fields
include::{snippets}/get-members/response-fields.adoc[]
=== 회원 정보 삭제
.curl-request
include::{snippets}/delete-member/curl-request.adoc[]
.http-request
include::{snippets}/delete-member/http-request.adoc[]
.path-parameters
include::{snippets}/delete-member/path-parameters.adoc[]
.http-response
include::{snippets}/delete-member/http-response.adoc[]
한번에 정리가 가능하도록 index.adoc
에서 다른 .adoc
자료들을 불러들이도록 하였습니다. 핸들러 메서드
별로 나뉘어 어떤 데이터들이 주고 받는지 JSON
과 Table
로 확인 할 수 있습니다.
이전에 학습해 두었 던 Rest API DOCS가 유용하게 사용되어 어렵지 않게 문서를 작성할 수 있었습니다. 다만 page같은 클래스는 잊어버릴 뻔 하여 가끔씩이라도 다시 보는게 중요하다 싶었습니다.