JAVA/API 문서
[자바 Spring] intellij로 복잡한 API문서를 자동으로 만들 수 있다고!? Spring Rest Docs에 대해 배워보자
_DoYun
2022. 7. 21. 12:39
API 문서란 개발자가 제작한 애플리케이션을 효과적으로 사용하기 위해 클라이언트(웹 브라우저)에게 제공하는 애플리케이션 사용법 문서입니다. 위 API 예시처럼 애플리케이션에서 꼭 필요한 각 필드들의 TYPE과 용도,필요성 등이 기입되게 됩니다.
일반적으로 API 문서는 개발자가 직접 작성하지만 Spring은 편리성을 위해 자동으로 API 문서 제작이 가능한 기능을 넣어두었습니다.
대표적으로 "Spring Rest Doc"와 "Sweggar"이 있습니다.
아래 코드는 Swagger 활용 예시입니다. 다른 코드는 다 필요없이 @Api가 들어간 에노테이션들만 보시면 됩니다.
보시다시피 거의 모든 메서드에 특정 에노테이션이 추가되어 있습니다. 비록 Swagger가 "postman"과 같이 Api 요청 툴로써 활용 가능하다는 장점이 있지만 무수히 많은 @Api 에노테이션이 필요하다는 점에서 효율성, 가독성 측면 불편한 부분이 존재합니다.
@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);
}
...
...
}
그렇다면 이번에는 Spring Rest Docs의 예시를 보겠습니다. 해당 코드 또한 맨 아래쪽 Api 문서화 관련 코드 시작 부터 보시면 됩니다. 정확히 .andDo 메서드가 있는 곳부터 입니다.
@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 문서화 관련 코드 끝========
}
}
분명 복잡해 보이고 분량도 적지 않지만 Swagger처럼 모든 메서드마다 특정 코드가 필요하지는 않습니다.
Spring Rest Docs를 사용한 API 문서화의 또 다른 장점은 테스트 케이스에서 전송하는 API 문서 정보와 Controller에서 구현한 Request Body, Response Body, Query Parmeter 등의 정보가 하나라도 일치하지 않으면 테스트 케이스의 실행 결과가 “failed” 되면서 API 문서가 정상적으로 생성이 되지 않는다는 것입니다.
즉, 여러분들이 테스트 케이스의 실행 결과를 “passed”로 만들지 않으면 API 문서 생성이 완료되지 않습니다.
이 말을 달리 표현하자면, 테스트 케이스의 실행 결과가 “passed”이면 Controller에 정의되어 있는 Request Body나 Response Body 등의 API 스펙 정보와 일치하는 API 문서가 만들어진다는 것입니다.
따라서 우리는 애플리케이션에 정의되어 있는 API 스펙 정보와 API 문서 정보의 불일치로 인해 발생하는 문제를 방지할 수 있습니다.
# Spring Rest Docs의 설정
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)
}
}
번호로 체크되어 있는 설정 정보들을 build.gradle에 넣어주세요
이제 본격적인 코드입니다. 저희가 배우고 싶은건 Spring Rest Docs이니까 위에서 말씀드렸던 것처럼 andDo 메서드, 즉 (9)번부터 집중하여 봐주시면 됩니다.
참고로 테스트 클래스 최상단에 있는 @AutoConfigureRestDocs 에노테이션은 Spring Rest Docs에 대한 자동 구성을 지원하는 에노테이션이라 꼭 넣어주셔야 됩니다.
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.getDocumentRequest;
import static com.codestates.util.ApiDocumentUtils.getDocumentResponse;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
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;
// (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)
MemberDto.response responseDto =
new MemberDto.response(1L,
"hgd@gmail.com",
"홍길동",
"010-1234-5678",
Member.MemberStatus.MEMBER_ACTIVE,
new Stamp());
// (5)
given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class))).willReturn(new Member());
// (6)
given(memberService.createMember(Mockito.any(Member.class))).willReturn(new Member());
// (7)
given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(responseDto);
// (8) 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( // (9)
"post-member", // (9-1)
getRequestPreProcessor(), // (9-2)
getResponsePreProcessor(), // (9-3)
requestFields( // (9-4)
List.of(
fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), // (9-5)
fieldWithPath("name").type(JsonFieldType.STRING).description("이름"),
fieldWithPath("phone").type(JsonFieldType.STRING).description("휴대폰 번호")
)
),
responseFields( // (9-6)
List.of(
fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
fieldWithPath("data.memberId").type(JsonFieldType.NUMBER).description("회원 식별자"), // (9-7)
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("스탬프 갯수")
)
)
));
}
}
실질적인 Spring Rest Docs의 코드는 (9)번 부터라 그 부분만 집중적으로 봐주시면 됩니다.
(9번) document(…) 메서드는 API 스펙 정보를 전달 받아서 실질적인 문서화 작업을 수행합니다.
document() 메서드의 첫 번째 파라미터인 (9-1)은 API 문서 스니핏의 식별자 역할을 하며, (9-1)에서 식별자의 이름을 “post-member”로 지정했기 때문에 문서의 스니핏은 post-member 디렉토리 하위에 생성됩니다.
*여기서 스니핏은 문서의 조각으로 인식하면 됩니다. Api 문서 내에서도 여러 페이지가 있는 것처럼 스니핏들이 모여서 하나의 Api 문서가 되는 것입니다.
(9-2)와 (9-3)의 코드는 따로 인터페이스에 저장한 코드들입니다.
public interface ApiDocumentUtils {
static OperationRequestPreprocessor getRequestPreProcessor() {
return preprocessRequest(prettyPrint());
}
static OperationResponsePreprocessor getResponsePreProcessor() {
return preprocessResponse(prettyPrint());
}
}
- preprocessRequest(prettyPrint()) 는 문서에 표시되는 JSON 포맷의 request body를 예쁘게 표현해 줍니다.
- preprocessResponse(prettyPrint()) 는 문서에 표시되는 JSON 포맷의 response body를 예쁘게 표현해 줍니다.
9-4)의 requestFields(…)는 문서로 표현될 request body를 의미하며, 파라미터로 전달되는 List<FieldDescriptor> 의 원소인 FieldDescriptor 객체가 request body에 포함된 데이터를 표현합니다.
즉, 아까 제일 처음에 봤던 사진처럼 서버의 Controller에 클라이언트들의 요청(ex) email = "hrd@naver.com"
이 어떤 것들인지 보여주고 정리하는 기능입니다.
d. (9-5)는 request body를 JSON 포맷으로 표현 했을 때, 하나의 프로퍼티를 의미하는 FieldDescriptor 입니다. type(JsonFieldType.STRING)은 JSON 프로퍼티의 값이 문자열 임을 의미합니다.
e. (9-6)의 responseFields(…)는 문서로 표현될 response body를 의미하며, 파라미터로 전달되는 List<FieldDescriptor> 의 원소인 FieldDescriptor 객체가 response body에 포함된 데이터를 표현합니다.
- JsonFieldType.OBJECT : JSON 포맷으로 표현 된 프로퍼티의 값이 객체임을 의미합니다.
- JsonFieldType.NUMBER : JSON 포맷으로 표현 된 프로퍼티의 값이 int나 long 같은 Number 임을 의미합니다.
이제 테스트 케이스를 실행하고, 실행 결과가 “passed”이면 우리가 작성한 API 스펙 정보를 기반으로 문서 스니핏이 만들어질 것입니다.
테스트가 PASSED 되면 위 사진처럼 build 파일 내에 generated-snippets 파일이 보일 것입니다. 그 안에 들어가 보면, 아래 사진처럼 post-member 파일이 보일 것입니다.
아까 저희가 위 코드에서 식별자로 "post-member" 이라는 이름으로 파일을 만들라고 작성했던 것 기억 나시나요? 그때 만든 스니핏들이 모여있는 파일이 바로 "post-member" 파일입니다.
request 정보와 response 정보가 들어있는 각각의 스니핏 파일들입니다.
이제 Api 문서를 만들기 위한 각각의 스니핏 파일들은 모두 자동으로 만들어졌습니다.
이제 이것들을 통합하여 하나의 Api 문서로 만들고 이를 HTML 형식으로 변환하여 시각적으로 보기 편한 콘텐츠로 제작할려고 합니다.
하나의 api 문서로 만들기 위해서는 위 같은 파일이 필요합니다. 이름을 정확히 표기하여 직접 만들어 주세요
HTML 언어를 접해보신 분들은 아시겠지만 HTML처럼 API 문서도 순서와 추가 환경 설정 및 여분의 글 작성이 가능합니다.
이것을 우리는 Asciidoc 언어라고 합니다.
더보기
Asciidoc이란 무엇일까요?
Asciidoc은 Spring Rest Docs를 통해 생성되는 텍스트 기반 문서 포맷입니다.
Asciidoc 포맷을 사용해서 메모, 문서, 기사, 서적, E-Book, 웹 페이지, 매뉴얼 페이지, 블로그 게시물 등을 작성할 수 있으며 Asciidoc 포맷으로 작성된 문서는 HTML, PDF, EPUB, 매뉴얼 페이지를 포함한 다양한 형식으로 변환될 수 있습니다.
또한 Asciidoc은 주로 기술 문서 작성을 위해 설계된 가벼운 마크업 언어이기도 합니다.
Spring Rest Docs를 통해 만들어지는 문서 스니핏과 이 문서 스니핏을 사용하는 템플릿 문서는 Asciidoc 포맷의 문서로 이루어져 있기 때문에 우리가 제공하는 API 문서를 사용하는 이들이 직관적으로 API 문서를 이해할 수 있는 수준 정도의 Asciidoc 기본 문법은 알고 있는 것이 좋습니다.
우리가 생성한 API 문서가 조금 더 세련되고, 가독성 좋은 문서로 만들어질 수 있도록 가벼운 마음으로 Asciidoc의 문법을 간단하게 살펴보겠습니다.
= 커피 주문 애플리케이션 // (1)
:sectnums: // (2)
:toc: left // (3)
:toclevels: 4 // (4)
:toc-title: Table of Contents // (5)
:source-highlighter: prettify // (6)
Hwang Jung Sik <jungsik.hwang@codestates.com>
v1.0.0, 2022.07.10
(1) 문서의 제목을 작성하기 위해서는 =를 추가하면 됩니다. ====와 같이 =의 개수가 늘어날 수록 글자는 작아집니다.
(2) 목차에서 각 섹션에 넘버링을 해주기 위해서는 :sectnums: 를 추가하면 됩니다.
(3) :toc: 는 목차를 문서의 어느 위치에 구성할 것인지를 설정합니다. 여기서는 문서의 왼쪽에 목차가 표시되도록 left를 지정했습니다.
(4) :toclevels: 은 목차에 표시할 제목의 level을 지정합니다. 여기서는 4로 지정했기 때문에 ==== 까지의 제목만 목차에 표시됩니다.
(5) :toc-title: 은 목차의 제목을 지정할 수 있습니다.
(6) :source-highlighter: 문서에 표시되는 소스 코드 하일라이터를 지정합니다. 여기서는 prettify를 지정했습니다.
***
== MemberController
=== 회원 등록
.curl-request // (1)
include::{snippets}/post-member/http-request.adoc[] // (2)
.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[]
...
...
(1)의 .curl-request 에서 .은 하나의 스니핏 섹션 제목을 표현하기 위해 사용합니다. curl-request 은 섹션의 제목이며, 원하는 대로 수정하면 됩니다.
(2)에서 include는 Asciidoctor에서 사용하는 매크로(macro) 중 하나이며, 스니핏을 템플릿 문서에 포함할 때 사용합니다. :: 은 매크로를 사용하기 위한 표기법입니다. {snippets}는 해당 스니핏이 생성되는 디폴트 경로를 의미하며, 우리가 아래의 [adoc-5] build.gradle 파일에 설정한 snippetsDir 변수를 참조하는데 사용할 수 있습니다.