Spring Rest Doc

2020-09-24

최근에 진행했던 프로젝트는 API문서화를 Swagger를 통해서 진행했었다.
Swagger를 사용하면서 Code에 덕지덕지 Annotation을 붙여나가니 코드가 너무 더러워(?) 보이기도 하고
중간중간 수정되는 부분에 대해 기민하게 대처가 되지 않았다.
그러던 와중 DS가 Spring Rest Doc에 대한 정보를 줘서 공부하면서 정리해 본다.

포스팅에 작성된 코드는 https://github.com/thinkub/spring-rest-doc/ 를 참고

Spring Rest Doc

공식 문서에서 소개된 내용을 번역하면 아래와 같다.

Spring Rest Doc은 작성 서비스에 대한 정확하고 읽기 쉬운 문서를 작성하는 데 있다. Spring REST Docs는 기본적으로 Asciidoctor를 사용한다. Asciidoctor는 일반 텍스트를 처리하고 HTML을 생산하며, 필요에 맞게 스타일링하고 배치한다. 원하는 경우 Markdown을 사용하도록 Spring REST 문서를 구성할 수도 있다.
Spring REST Docs는 Spring MVC의 테스트 프레임워크, Spring WebFlux의 WebTestClient 또는 REST Assured으로 작성된 테스트에 의해 생산된 스니펫을 사용한다. 이러한 테스트 기반 접근 방식은 서비스 설명서의 정확성을 보장하는 데 도움이 된다. 코드 조각이 틀리면 이를 생성하는 테스트가 실패한다.

간단하게 말해 Test Code기반으로 API문서를 생성해준다.
그렇기 때문에 Controller Layer에서의 테스트 코드는 필수 적으로 작성이 되어야 하며 통과가 되어야 한다.

시작하기

Requirements

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
plugins {
id 'org.springframework.boot' version '2.3.4.RELEASE'
id 'io.spring.dependency-management' version '1.0.10.RELEASE'
id 'org.asciidoctor.convert' version '1.5.9.2'
id 'java'
}

group = 'com.ming'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
compileOnly {
extendsFrom annotationProcessor
}
}

repositories {
mavenCentral()
}

ext {
set('snippetsDir', file("build/generated-snippets"))
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
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') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
testImplementation "org.junit.jupiter:junit-jupiter-api"
testImplementation "org.junit.jupiter:junit-jupiter-params"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine"
}

test {
outputs.dir snippetsDir
useJUnitPlatform()
}

asciidoctor {
inputs.dir snippetsDir
dependsOn test
}

asciidoctor.doFirst {
delete file('src/main/resources/static/docs')
}

task copyDocument(type: Copy) {
dependsOn asciidoctor
from file("build/asciidoc/html5")
into file("src/main/resources/static/docs")
}

build {
dependsOn copyDocument
}

bootJar {
dependsOn asciidoctor
from ("${asciidoctor.outputDir}/html5") {
into 'static/docs'
}
}

Sample User Api

UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;

@GetMapping("/{userSeq}")
public ResponseEntity<User> findUser(@PathVariable Long userSeq) {
User user = userService.findUserByUserSeq(userSeq);
return ResponseEntity.ok().body(user);
}

@PostMapping
public ResponseEntity<User> createUser(@RequestBody User.Create createUser) {
User user = userService.createUser(createUser);
return ResponseEntity.ok().body(user);
}

@PutMapping("/{userSeq}")
public ResponseEntity<User> modifyUser(@PathVariable Long userSeq, @RequestBody User.Modify modify) {
User user = userService.modifyUser(userSeq, modify);
return ResponseEntity.ok().body(user);
}

@DeleteMapping("/{userSeq}")
public ResponseEntity deleteUser(@PathVariable Long userSeq) {
userService.deleteUser(userSeq);
return ResponseEntity.ok().build();
}
}

UserController Test Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
@ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
@WebMvcTest(UserController.class)
@AutoConfigureRestDocs
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Autowired
private ObjectMapper objectMapper;

@Test
void findUser() throws Exception {
// given
given(userService.findUserByUserSeq(1L)).willReturn(User.of(1L, "thinkub", "Ming"));

// when
ResultActions resultActions = mockMvc.perform(RestDocumentationRequestBuilders.get("/user/{userSeq}", 1L)
.accept(MediaType.APPLICATION_JSON_VALUE))
.andDo(MockMvcResultHandlers.print());

// then
resultActions.andExpect(status().isOk())
.andDo(document(
"findUser",
getDocumentRequest(),
getDocumentResponse(),
pathParameters(parameterWithName("userSeq").description("사용자 시퀀스")),
responseFields(
fieldWithPath("userSeq").type(JsonFieldType.NUMBER).description("사용자 시퀀스"),
fieldWithPath("userId").type(JsonFieldType.STRING).description("아이디"),
fieldWithPath("userName").type(JsonFieldType.STRING).description("이름")
)
));
}

@Test
void createUser() throws Exception {
// given
User.Create create = User.Create.of("thinkub", "Ming");
given(userService.createUser(any())).willReturn(User.of(1L, "thinkub", "Ming"));

// when
ResultActions resultActions = mockMvc.perform(RestDocumentationRequestBuilders.post("/user")
.accept(MediaType.APPLICATION_JSON_VALUE)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsString(create)))
.andDo(MockMvcResultHandlers.print());

// then
resultActions.andExpect(status().isOk())
.andDo(document(
"createUser",
getDocumentRequest(),
getDocumentResponse(),
requestFields(
fieldWithPath("userId").type(JsonFieldType.STRING).description("아이디"),
fieldWithPath("userName").type(JsonFieldType.STRING).description("이름").optional()
),
responseFields(
fieldWithPath("userSeq").type(JsonFieldType.NUMBER).description("사용자 시퀀스"),
fieldWithPath("userId").type(JsonFieldType.STRING).description("아이디"),
fieldWithPath("userName").type(JsonFieldType.STRING).description("이름")
)
));
}

@Test
void modifyUser() throws Exception {
// given
User.Modify modify = User.Modify.of("thinkub-new", "Ming-new");
User user = User.of(1L, "thinkub-new", "Ming-new");
given(userService.modifyUser(eq(1L), any())).willReturn(user);

// when
ResultActions resultActions = mockMvc.perform(RestDocumentationRequestBuilders.put("/user/{userSeq}", 1L)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(modify)))
.andDo(MockMvcResultHandlers.print());

// then
resultActions.andExpect(status().isOk())
.andDo(document(
"modifyUser",
getDocumentRequest(),
getDocumentResponse(),
pathParameters(parameterWithName("userSeq").description("사용자 시퀀스")),
requestFields(
fieldWithPath("userId").type(JsonFieldType.STRING).description("아이디").optional(),
fieldWithPath("userName").type(JsonFieldType.STRING).description("이름").optional()
),
responseFields(
fieldWithPath("userSeq").type(JsonFieldType.NUMBER).description("사용자 시퀀스"),
fieldWithPath("userId").type(JsonFieldType.STRING).description("아이디"),
fieldWithPath("userName").type(JsonFieldType.STRING).description("이름")
)
));

}

@Test
void deleteUser() throws Exception {
// given
doNothing().when(userService).deleteUser(1L);

// when
ResultActions resultActions = mockMvc.perform(RestDocumentationRequestBuilders.delete("/user/{userSeq}", 1L)
.accept(MediaType.APPLICATION_JSON_VALUE))
.andDo(MockMvcResultHandlers.print());

// then
resultActions.andExpect(status().isOk())
.andDo(document(
"deleteUser",
getDocumentRequest(),
getDocumentResponse(),
pathParameters(parameterWithName("userSeq").description("사용자 시퀀스"))
));
}
}

https://docs.spring.io/spring-restdocs/docs/current/reference/html5/