- [spring] springrestdocs 를 활용한 restful api 확인
- [spring] springrestdocs 를 활용한 restful api 확인 _enum 코드 문서화
- [spring] springrestdocs 를 활용한 restful api 확인_refactoring
[spring] springrestdocs 를 활용한 restful api 확인
저번에 RestAPI 를 간략하게 정리하면서 제 토이프로젝트에는 REST API 가 제대로 적용되지 않았다는 걸 깨달았습니다. 제대로 공부하기 전까진 그냥 GET, POST 만 써서 uri 로 연결하면 되는 줄 알았거든요…
[web] restapi 제가 RestAPI 에 대해 정리한 문서입니다.
그래서 API 를 리팩토링하는 겸, Spring REST Docs를 공부하고 함께 적용하기로 했습니다.
툴은 Swagger 와 Spring REST Docs 중에 고민했었는데요. Spring REST Docs 가 깔끔해보여서… 마음이 가는대로 정했습니다. ㅎㅎ (실제 코드에 추가되는 코드가 없다는 게 끌렸습니다.) 아래 포스팅에서 간략히 비교해보겠습니다.
Spring REST Docs
Spring Rest Docs 는 API 를 명세화하기 위한 툴입니다. 프로젝트를 진행하면서 API 를 만들고, 통합하고 사용할 때 API 의 명세화는 필수 입니다. 하지만 이러한 API 를 일일히 수동으로 문서화하는 것은 매우 힘든 작업이며, 프로젝트 핵심 로직에서 벗어난 일입니다.
예를 들어서 API 가 변경되었을 때 문서를 적시에 변경해주지 않으면 API 를 사용하는 입장에서는 API 문서대로 요청했음에도 제대로 된 응답을 받을 수 없게 됩니다.
따라서 API 명세화의 자동화가 필요한데 그를 위한 대표적인 툴 중 하나로 Spring Rest Docs 가 있습니다.
Rest Docs 는 테스트 코드를 기반으로 Asciidoctor 를 사용하여 Restful 문서를 생성하고 테스트로 생성된 snippet 을 사용하여 snippet 이 올바르지 않으면 생성된 테스트가 실패하여 정확성을 보장해줍니다.
특징
- Test-driven documentation 입니다. 문서는 API 를 기반으로 한 테스트를 통해서 생성되며, 이를 통해 문서는 항상 최신화된 상태를 유지합니다. 따라서 테스트가 성공해야 문서가 작성되어 API 의 신뢰도를 높이고 테스트 코드 검증을 강제로 하게 합니다.
- 프로덕션 코드와 분리되어 있어 실제 코드에 추가되는 코드가 없습니다. (제가 가장 마음에 들었던 부분입니다.)
Swagger vs Spring REST Docs
위에서 서술한 것처럼 Swagger 와 Spring Rest Docs 중에 많은 고민을 했는데, 둘의 차이를 개략적으로 짚고 넘어가겠습니다.
Spring Rest Docs | Swagger | |
---|---|---|
장점 | 제품코드에 영향 없다. | API 를 테스트 해 볼수 있는 화면을 제공한다. |
테스트가 성공해야 문서작성된다. | 적용하기 쉽다. | |
단점 | 적용하기 어렵다. | 제품코드에 어노테이션 추가해야한다. |
제품코드와 동기화가 안될수 있다. |
Spring REST Docs 사용
gradle.build
Rest Docs 는 gradle 부터 난관입니다. gradle 문법은 다음에 따로 포스팅해보겠습니다.
먼저 Java17, SpringBoot 3.0.6, Gradle 7.6.1 을 사용했습니다. build 에서 간단한 건 주석, 설명이 필요한 건 주석에 번호를 달아서 아래에 적어보겠습니다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.0.6'
id 'io.spring.dependency-management' version '1.1.0'
//asciidoctor 파일을 컨버팅하고 build 폴더에 복사하기 위한 플러그인
id 'org.asciidoctor.jvm.convert' version '3.3.2'
}
...
configurations {
//asciicodctorExt 를 configuration 으로 지정
asciidoctorExt
compileOnly {
extendsFrom annotationProcessor
}
}
...
dependencies {
...
//spring rest docs
//adoc 파일에서 사용할 snippets 속성이 자동으로 build/generated-snippets 를 가리키게 함
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:3.0.0'
}
...
ext {
//snippets 파일이 저장될 경로를 snippetsDir 로 변수 설정
snippetsDir = file('build/generated-snippets')
}
test {
outputs.dir snippetsDir
useJUnitPlatform()
}
asciidoctor {
//Asciidoctor 에서 asciidoctorExt 설정을 사용하도록 함
configurations 'asciidoctorExt'
//1
baseDirFollowsSourceFile()
//input 디렉토리를 SnippetsDir 로 설정합니다.
inputs.dir snippetsDir
//2
dependsOn test
}
asciidoctor.doFirst {
//기존에 생성된 파일은 지운 후 새로운 파일을 만들 준비를 합니다.
delete file('src/main/resources/static/docs')
}
//from 에 위치한 파일들은 into 로 복사하는 task
task createDocument(type: Copy) {
//asciidoctor 실행 후 실행됨
dependsOn asciidoctor
from file("build/docs/asciidoc/")
into file("src/main/resources/static")
}
//생성된 문서를 jar 파일에 패키징하는 설정
bootJar {
//createDocument 이후 실행됨
dependsOn createDocument
//3
from ("${asciidoctor.outputDir}") {
into 'static/docs'
}
}
- .adoc 파일에서 다른 .adoc 파일을 include 하는 경우가 있습니다. 그 때 동일한 경로를 baseDir 로 설정하도록 합니다. gradle 6 에서는 자동으로 설정되었지만 gradle 7 부터 baseDir 을 각각 설정해야 한다고 합니다.
- test 가 성공적으로 실행되어야만 asciidoctor 가 실행된다는 것을 명시한 코드입니다.
- build 시 asciidoctor.outputDir 에 html 파일이 생깁니다. 해당 파일을 jar 안에 있는 /resources/static 폴더에 복사합니다.
예제 설정
예제는 Member 를 만드는 Member, MemberRepository, MemberService, MemberController, Dto 와 Post 를 만드는 Post, PostRepository, PostService, PostController, Dto 가 있습니다. 원래 다 올리려고 했는데 너무 길어질 것 같아 깃허브로 대체하겠습니다. 깃허브 이동
src > main 디렉토리만 깃허브로 대체하겠습니다. 물론 restdocs 설정이나 gradle 도 있긴 합니다만 아래에서 설명하겠습니다.
혹시나 src > main 그대로 따라하시는 분들은 h2 사용하셔야 합니다.
MockMvc 를 이용한 테스트 코드 작성
Post
이것도 주석으로 하나하나 달아보겠습니다. import 가 겹치는 게 많아 import 도 남겨놨습니다.
package restapi.restdocs.controller;
import org.assertj.core.util.Lists;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import restapi.restdocs.dto.PostResponse;
import restapi.restdocs.service.PostService;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.when;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*;
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.status;
@ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
@SpringBootTest
class PostControllerTest {
private MockMvc mockMvc;
@BeforeEach
public void setUp(WebApplicationContext webApplicationContext,
RestDocumentationContextProvider restDocumentation) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
//해당 설정을 통해 문서화를 할 수 있습니다.
.apply(documentationConfiguration(restDocumentation))
.build();
}
@MockBean
private PostService postService;
@Test
void create() throws Exception {
final PostResponse postResponse = new PostResponse(1L, "title", "content");
when(postService.create(any())).thenReturn(postResponse);
this.mockMvc.perform(post("/posts") //요청 방식을 선택합니다.
.content("{\"title\": \"title\", \n\"content\": \"content\"}") //보낼 데이터를 입력합니다.
.contentType(MediaType.APPLICATION_JSON)) //contentType 을 설정합니다.
.andExpect(status().isCreated()) //정상 동작 시 응답 상태코드입니다.
.andDo(document("post-create", //post-create 문서를 만듭니다.
requestFields( //requestFields 를 만들어줍니다.
fieldWithPath("title").description("Post 제목"), //문서에 설정될 필드와 설명을 설정합니다.
fieldWithPath("content").description("Post 내용").optional() //null 일수도 있으므로 optional() 로 설정합니다.
)
));
}
@Test
void findAll() throws Exception {
List<PostResponse> postResponses = Lists.newArrayList(
new PostResponse(1L, "title1", "content1"),
new PostResponse(2L, "title2", "content2")
);
when(postService.findAll()).thenReturn(postResponses);
ObjectMapper objectMapper = new ObjectMapper();
String expectedJson = objectMapper.writeValueAsString(postResponses);
this.mockMvc.perform(get("/posts")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().json(expectedJson)) //api 호출 시 기대값을 설정합니다.
.andDo(document("post-get-all",
responseFields( //responseFields 를 만들어줍니다.
fieldWithPath("[].id").description("Post Id"), //List 형식은 [].id 처럼 작성합니다.
fieldWithPath("[].title").description("Post 제목"),
fieldWithPath("[].content").description("Post 내용")
)
));
}
@Test
void findById() throws Exception {
PostResponse postResponse = new PostResponse(1L, "title", "content");
when(postService.findById(anyLong())).thenReturn(postResponse);
this.mockMvc.perform(get("/posts/{postId}", postResponse.getId())
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andDo(document("post-get-one",
pathParameters( //pathVariable 을 받을 때 명시해줍니다.
parameterWithName("postId").description("Post Id")
),
responseFields(
fieldWithPath("id").description("Post Id"),
fieldWithPath("title").description("Post 제목"),
fieldWithPath("content").description("Post 내용")
)
));
}
@Test
void update() throws Exception {
this.mockMvc.perform(put("/posts/{postId}", 1L)
.content("{\"title\": \"turtle\", \n\"content\": \"context\"}")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andDo(document("post-update",
pathParameters(
parameterWithName("postId").description("Post Id")
),
requestFields(
fieldWithPath("title").description("Post 제목"),
fieldWithPath("content").description("Post 내용")
)
));
}
@Test
void remove() throws Exception {
this.mockMvc.perform(delete("/posts/{postId}", 1L))
.andExpect(status().isNoContent())
.andDo(document("post-delete",
pathParameters(
parameterWithName("postId").description("Post Id")
)
));
}
}
Member
member 와 post 는 겹치니까 주석을 별도로 달지 않겠습니다.
package restapi.restdocs.controller;
@ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
@SpringBootTest
class MemberControllerTest {
private MockMvc mockMvc;
@BeforeEach
public void setUp(WebApplicationContext webApplicationContext,
RestDocumentationContextProvider restDocumentation) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation))
.build();
}
@MockBean
private MemberService memberService;
@Test
void signUp() throws Exception {
final MemberResponse memberResponse = new MemberResponse("memberName", "title", "content");
when(memberService.signUp(any())).thenReturn(memberResponse);
this.mockMvc.perform(post("/members/signup")
.content("{\"memberName\": \"memberName\", \"password\": \"password\", \"email\": \"email@example.com\"}") // Updated JSON payload
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isCreated())
.andDo(document("member-signup",
requestFields(
fieldWithPath("memberName").description("Member memberName"),
fieldWithPath("password").description("Member password"),
fieldWithPath("email").description("Member email")
)
));
}
@Test
void login() throws Exception {
final MemberResponse memberResponse = new MemberResponse("memberName", "title", "content");
when(memberService.login(any())).thenReturn(memberResponse);
this.mockMvc.perform(post("/members/login")
.content("{\"memberName\": \"memberName\", \"password\": \"password\"}") // Updated JSON payload
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andDo(document("member-login",
requestFields(
fieldWithPath("memberName").description("Member memberName"),
fieldWithPath("password").description("Member password")
)
));
}
@Test
void remove() throws Exception {
this.mockMvc.perform(delete("/members/{memberName}", "memberName"))
.andExpect(status().isNoContent())
.andDo(document("member-delete",
pathParameters(
parameterWithName("memberName").description("member memberName")
)
));
}
문서화
build
를 하면 build/generated-snippets
에 다음과 같이 .adoc
파일이 생깁니다.
이제 이 파일들을 모아서 보여줄 수 있도록 문서화를 하겠습니다.
문서를 하기 위해선 src 디렉토리에 docs/asciidoc
디렉토리를 만들어야 합니다. 해당 디렉토리 하위에 만드는 *.adoc
파일이 src/main/resources/static
으로 문서화되어 이동합니다.
src/docs/aciidoc/index.adoc
모든 adoc 파일을 한번에 만들기 위해서 index.adoc
파일을 만들겠습니다.
= Rest Docs Practice Application API Document
:doctype: book
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:
include::member.adoc[]
include::post.adoc[]
-
=
은 h1 과 같습니다.==
는 h2,===
는 h3 입니다. -
:source-highlighter: highlightjs
: http 에 구분 색을 넣어줍니다. 가독성을 올려줍니다. -
:toc: left
: 왼쪽에 메뉴와 같은 toc 를 만들어줍니다. -
:toclevels: 2
: toc level 을 2로 설정합니다. 하위 2개 level 까지 나타냅니다. -
:sectlinks:
해당 API 의 제목을 클릭하면 해당 API 로 이동할 수 있게 합니다. -
include::member.adoc[]
: 같은 디렉토리에 있는member.adoc
파일을 포함해서 문서화를 합니다.- 이런 식으로 만들어질겁니다. (현재 단계에서는 아니며, 아래 단계를 따라 member.adoc, post.adoc 을 만들고 build 를 해야 합니다.)
-
AsciiDoc 플러그인을 사용하면
.adoc
에서 다음과 같이 보여집니다.
member.adoc
이제 index.adoc
에 들어갈 member.adoc
파일을 만들어보겠습니다.
== 멤버
=== 회원가입
operation::member-signup[snippets='http-request,http-response']
=== 로그인
operation::member-login[snippets='http-request,http-response']
=== 멤버 삭제
operation::member-delete[snippets='http-request,http-response']
operation::member-signup[snippets='http-request,http-response']
:build/generated-snippet
에서member-signup
폴더의http-request
,http-response
adoc 파일을 가져옵니다. 나머지도 똑같습니다.
post.adoc
== 게시글
=== 게시글 전체 조회
operation::post-get-all[snippets='http-request,http-response']
=== 게시글 1개 조회
operation::post-get-one[snippets='http-request,http-response']
=== 게시글 등록
operation::post-create[snippets='http-request,http-response']
=== 게시글 수정
operation::post-update[snippets='http-request,http-response']
=== 게시글 삭제
operation::post-delete[snippets='http-request,http-response']
- 위
member.adoc
과 똑같습니다. 이렇게 만들어진.adoc
파일이index.adoc
에서 함께 보여집니다.
JSON prettyPrint
빌드
이제 빌드를 해봅시다. /gradlew build
로 빌드를 하면 main/resources/static
에 index.html, member.html, post.html 문서가 생깁니다. 그 중 index.html 을 사용하면 됩니다.
문서 커스텀 하기
prettyPrint
지금 JSON 형식을 보시면 아래와 같이 한줄로 되어있습니다.
Test 에서 @BeforeEach 에 .withResponseDefaults(prettyPrint())
설정을 추가하여 보기 쉽도록 하겠습니다.
@BeforeEach
public void setUp(WebApplicationContext webApplicationContext,
RestDocumentationContextProvider restDocumentation) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation)
.operationPreprocessors()
.withRequestDefaults(prettyPrint())
.withResponseDefaults(prettyPrint()))
.build();
}
설정을 추가하면 아래와 같이 됩니다.
ResponseField 넣기, 커스텀
각 요청 혹은 응답마다 필요한 field 의 이름, type, Desciption 이 있습니다. 이를 표로 만들어서 index 에 넣어주도록 하겠습니다. 다음과 같이 넣으면 오른쪽과 같이 보여집니다.
하지만 API 문서를 보면서 필요한 게 위 3가지만은 아니겠죠. Optional 여부, Constraints 같은 조건도 필요합니다. Optional 은 test 의 Field 에 .optional
로 설정되어있으므로 제약조건 설정해보겠습니다. 이를 위해 먼저 config 파일은 RestDocsConfig.java
파일을 test 폴더 내에 만들어 줍니다.
package restapi.restdocs.config;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.restdocs.snippet.Attributes.Attribute;
public class RestDocsConfig {
public static Attribute field(
final String key,
final String value){
return new Attribute(key,value);
}
}
- 나중에 configuration 설정으로 쓰기 위해 이름을
~Config
라고 했습니다.
이제 PostControllerTest 에서 .attributes(field("constraints", "길이 100 이하"))
를 붙이겠습니다.
@Test
void create() throws Exception {
// Change the postResponse object
final PostResponse postResponse = new PostResponse(1L, "title", "content");
when(postService.create(any())).thenReturn(postResponse);
this.mockMvc.perform(post("/posts")
.content("{\"title\": \"title\", \n\"content\": \"content\"}")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isCreated()) // Change the expected status code
.andDo(document("post-create",
requestFields(
fieldWithPath("title").description("Post 제목").attributes(field("constraints", "길이 100 이하")),
fieldWithPath("content").description("Post 내용").optional()
)
));
}
이까지 했다고 .adoc 파일의 테이블에 optional 이나 contraints 가 추가되는 건 아닙니다. 추가되는 형식을 바꿔줘야 하는데요. src/test/resources/org/springframework/restdocs/templates
경로에 request-fields.snippet
와 response-fields.snippet
파일을 추가하면 됩니다. 문법은 mustache 입니다. 두 형식이 똑같기 때문에 하나만 적을게요.
|===
|Path|Type|Description|Contraints|Optional
|`++`
|`++`
|
|
|NoYes
|===
이제 이런 식으로 나옵니다.
댓글남기기