-
[SpringBoot] Spring REST Docs ๋ก API ๋ช ์ธ๋ฅผ ๋ฌธ์ํ ํ์SpringBoot 2024. 1. 23. 19:38
๐ญ ๋ค์ด๊ฐ๋ฉฐ
์ต๊ทผ, ์ด๋ฒคํธ ์คํ ๋ฐ์ ํ์๋ค๊ณผ ํ๋ฉฐ ๋๋์ ์ธ ๋ฆฌํฉํ ๋ง์ ์งํํ๋ค.
์ด๋ฒคํธ ์คํ ๋ฐ ๊ตฌ๊ฒฝํ๋ฌ ๊ฐ๊ธฐ
ํ์น์น์ ์ด๋ฒคํธ ์คํ ๋ฐ
์ด๋ฒคํธ ์คํ ๋ฐ ์๊ฐ ๋ฐ ๊ฒฝํ ๊ณต์
hufscheer-techblog.vercel.app
๋๋ฉ์ธ ์ฉ์ด๋ค์ ๋๋ค์ ๋ณ๊ฒฝํ๋ค ๋ณด๋, ์๋ํฌ์ธํธ๋ ๋ค์ ๋ณ๊ฒฝ๋์๋๋ฐ ํด๋น ๋ณ๊ฒฝ์ฌํญ๋ค์ ๋ชจ๋ ์ผ์ผ์ด ๋ ธ์ ์ ๊ธฐ์ ํ๋ ค๋ ๋ถํธํจ๊ณผ ๋ถ์ํจ์ด ์๊ฒผ๋ค.
์์ฑํ API ์คํ์ด ๋ง๋์ง ํท๊ฐ๋ ธ๊ณ , ํ์๋ถ๊ณผ์ ์์๋ฅผ ํตํด API ๋ฌธ์ํ๋ฅผ ํ ์ ์๋ Spring REST Docs ๋ฅผ ๋์ ํ๊ธฐ๋ก ํ๋ค.
๐ฌ Spring REST Docs ์ ์ ์ฒด์ ์ธ ํ๋ก์ฐ
์ฐ์ , ์ ์ฒด์ ์ธ ํ๋ก์ฐ์ ๋ํ ์ดํด๋ฅผ ๋จผ์ ํ๊ณ ๋ ๋ค์ ์ฝ๋๋ฅผ ์ดํด๋ณด๋ฉด ๋ ๋น ๋ฅธ ์ดํด๊ฐ ๊ฐ๋ฅํ ๊ฒ ๊ฐ์ ํ๋ก์ฐ๋ฅผ ๋จผ์ ์ง์ด๋ณด์.
1. ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํ๋ค.
2. ํ ์คํธ ์ฝ๋๋ฅผ ๊ธฐ๋ฐ์ผ๋ก build/generated-snippets ๋ด๋ถ์ ์ฝ๋ ์ค๋ํซ์ด ์์ฑ๋๋ค.
3. ํด๋น ์ค๋ํซ์ ๊ธฐ๋ฐ์ผ๋ก adoc ํ์ผ์ API ๋ช ์ธ๋ฅผ ์์ฑํ๋ค.
4. adoc ํ์ผ์ ๊ธฐ๋ฐ์ผ๋ก html ํ์ผ์ด ์์ฑ๋๋ค.
5. ํด๋น html ํ์ผ์ ์๋นํ๋ค.
๐ build.gradle ์์ฑ
Spring REST Docs ์ ํต์ฌ์ ๋น๋ ํ๊ฒฝ ์ธํ ์ด๊ธฐ์ ์ด๋ฅผ ์ ์ดํดํ๊ณ ๋์ด๊ฐ๋ ๊ฒ์ด ์ค์ํ๋ค.
plugins { id 'java' id 'org.springframework.boot' version '3.1.4' id 'io.spring.dependency-management' version '1.1.3' id "org.asciidoctor.jvm.convert" version '3.3.2' // 1 } configurations { asciidoctorExt // 2 } dependencies { // ์ค๋ต.. // rest docs asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' // 3 testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' // 4 } ext { snippetsDir = file('build/generated-snippets') // 5 } test { outputs.dir snippetsDir // 6 useJUnitPlatform() } asciidoctor { configurations 'asciidoctorExt' // 7 inputs.dir snippetsDir // 8 dependsOn test } tasks.register('copyDocument', Copy) { // 9 dependsOn asciidoctor from file("build/docs/asciidoc") into file("src/main/resources/static/docs") } asciidoctor.doFirst { // 10 delete file('src/main/resources/static/docs') } bootJar { // 11 dependsOn copyDocument }
1๏ธโฃ org.asciidoctor.jvm.convert
asciidoctor ๋ฅผ jvm ์์ ์ฌ์ฉํ ์ ์๋๋ก ํ๋ ํ๋ฌ๊ทธ์ธ์ด๋ค.
asciidoctor ์ asciidoc ๋ฌธ์๋ฅผ HTML, PDF ๋ฑ์ผ๋ก ๋ณํํ๋ ์ญํ ์ ํ๋ค.
2๏ธโฃ asciidoctorExt
asciidoctor ์ ๊ด๋ จ๋ ๊ตฌ์ฑ๋ค์ ์ค์ ํ๋ค.
3๏ธโฃ asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
Spring REST Docs ์์ ์์ฑ๋ ์ค๋ํซ์ asciidoctor ๋ก ๋ณํํ ๋ ํ์ํ ์์กด์ฑ์ ์ ๊ณตํ๋ค.
4๏ธโฃ org.springframework.restdocs:spring-restdocs-mockmvc
์ค์ ์์ฒญ์ ๋ณด๋ด์ง ์๊ณ Mock ๊ฐ์ฒด๋ฅผ ์ด์ฉํด์ ํ ์คํธํ ์ ์๋๋ก ํด์ฃผ๋ ์์กด์ฑ์ด๋ค.
5๏ธโฃ snippetsDir = file('build/generated-snippets')
snippetsDir ์ ์ ์ญ์ ์ผ๋ก ์ ์ธํ๋ค.
6๏ธโฃ outputs.dir snippetsDir
ํ ์คํธ์ ๊ฒฐ๊ณผ๋ฅผ 5์์ ์ ์ธํ ๊ฒฝ๋ก์ ์ ์ฅํ ๊ฒ์ ์ ์ธํ๋ค.
7๏ธโฃ configurations 'asciidoctorExt'
asciidoctor ํ๋ฌ๊ทธ์ธ์ด ์ฐธ์กฐํ ๊ตฌ์ฑ์ ์ ํํ๋ค.
8๏ธโฃ inputs.dir snippetsDir
asciidoctor ์์ ์ฐธ์กฐํ๋ ํ์ผ์ ์์น๋ฅผ ๋ํ๋ธ๋ค.
9๏ธโฃ tasks.register('copyDocument', Copy)
9๋ build/docs/asciidoc ์ ์๋ ํ์ผ์ src/main/resources/static/docs ๋ก ๋ณต์ฌํ๋ ํ์คํฌ๋ฅผ ์ ์ธํ๋ค.
๐ asciidoctor.doFirst
10์ asciidoctor ๊ฐ ์ด๋ฃจ์ด์ง ๋ ๋จผ์ src/main/resources/static/docs ์ ์์นํ ํ์ผ๋ค์ ์ญ์ ํ๋ ๊ฒ์ด๋ค. ์ด๋ asciidoctor ๊ฐ test ์ ์์กดํ๊ธฐ ๋๋ฌธ์, test ๊ฐ ๋จผ์ ์ด๋ฃจ์ด์ง๋ฉด, ํด๋น ํ์คํฌ๊ฐ ์ด๋ฃจ์ด์ง๋ค.
1๏ธโฃ1๏ธโฃ dependsOn copyDocument
11์ jar ํ์ผ์ด ์์ฑ๋๋ ๋ถ๋ถ์ด๋ค. ์ด๋ jar ํ์ผ์ด ์์ฑ๋ ๋ ํญ์ 9์์ ์ ์ํ ๋ณต์ฌ ํ์คํฌ๊ฐ ๋จผ์ ์ด๋ฃจ์ด์ง๋๋ก ํ๋ค.
๐ DocumentationTest ํด๋์ค ์์ฑ
@WebMvcTest(controllers = { CheerTalkController.class, // ์ค๋ต .. }) @AutoConfigureRestDocs public class DocumentationTest { private static final OperationRequestPreprocessor HOST_INFO = preprocessRequest(modifyUris() .scheme("https") .host("์ฃผ์") .removePort(), prettyPrint() ); protected static final RestDocumentationResultHandler RESULT_HANDLER = document( "{class-name}/{method-name}", HOST_INFO, preprocessResponse(prettyPrint()) ); @Autowired protected MockMvc mockMvc; @Autowired protected ObjectMapper objectMapper; // ์ค๋ต .. }
๋ชจ๋ ํ ์คํธ๊ฐ ์์๋ฐ์ ํด๋์ค๋ฅผ ์์ฑํ๋ค.
ํด๋น ํด๋์ค์์๋ ๋ชจ๋ ํ ์คํธ์ ํด๋นํ๋ ์ค์ ์ ํด์ค๋ค.
@WebMvcTest
์ด๋ ์น ๊ณ์ธต ํ ์คํธ์์ ์ฌ์ฉ๋๋ ์ด๋ ธํ ์ด์ ์ด๋ค.
์ด๋ฅผ ์ด์ฉํด ํ์ํ ๊ตฌ์ฑ์ด๋ ๋น๋ค๋ง ๋ก๋ํ๋๋ก ํ์ฌ ์๋๋ฅผ ๋์ผ ์ ์๋ค.
@AutoConfigureRestDocs
์ด๋ฅผ ์ด์ฉํ๋ฉด ์๋์ผ๋ก REST Docs ๋ฅผ ๊ตฌ์ฑํด์ค๋ค.
ํด๋น ์ด๋ ธํ ์ด์ ์ ๋ถ์ด๋ฉด build/generated-snippets ์ ์ค๋ํซ์ด ์ ์ฅ๋๋ค.
HOST_INFO
ํด๋น ๋ถ๋ถ์ ๋ช ์ธ ๋ด์์ ํธ์คํธ์ ์ฃผ์๋ก ๋ช ์๋๊ฒ ๋๋ค.
RESULT_HANDLER
REST Docs ์ ์ฌ์ฉ๋๋ ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํ๋ค ๋ณด๋ฉด, ๋ฐ๋ณต๋๋ ๋ถ๋ถ๋ค์ด ์กด์ฌํ๋ค.
ํด๋น ์ค๋ํซ์ identifier, prettyPrint ๊ฐ ๊ทธ๋ ๋ค.
identifier ๋ ๋ค์๊ณผ ๊ฐ์ด build/generated-snippets ์ ์์นํ ์ค๋ํซ๋ค์ ๊ฒฝ๋ก๋ฅผ ๋ํ๋ธ๋ค.
๋ง์ผ ์์ฒ๋ผ, RESULT_HANDLER ๋ฅผ ์ ์ธํด๋์ง ์๋๋ค๋ฉด, ๋ค์๊ณผ ๊ฐ์ด ๋งค๋ฒ identifier ์ HOST_INFO, response ์ ๋ํ prettyPrint ๋ฅผ ์ง์ ํด์ค์ผ ํ๋ค.
result.andExpect(status().isOk()) .andDo(document("identifier์ ํด๋นํ๋ ๋ฌธ์์ด", HOST_INFO, preprocessResponse(prettyPrint())
์ด์ฒ๋ผ identifier ๋ฅผ ์ง์ ํ๊ฒ ๋๋ฉด, ํด๋์ค๋ง๋ค ๋ค๋ฅธ ์ด๋ฆ์ ์ฌ์ฉํ๊ฒ ๋ ์ฐ๋ ค๊ฐ ์๊ธฐ ๋๋ฌธ์ ์์ฒ๋ผ ์์๋ก ์ง์ ํด ๊ณ ์ ์ ์ผ๋ก ํด๋์ค ์ด๋ฆ / ๋ฉ์๋ ์ด๋ฆ์ผ๋ก ์ค๋ํซ์ด ์์ฑ๋๋๋ก ํ๋ค.
prettyPrint
์ด๋ ์ฌ๋์ด ์ฝ๊ธฐ ์ฝ๊ฒ ์ข ๋ ์ ์ ํด์ ์ถ๋ ฅํ๋ ์ต์ ์ด๋ค.
chatGPT ์ ๋ฐ๋ฅธ prettyPrint ์ต์ ์ด ์กด์ฌํ ๋
|=== |Path |Method |Status |Response-Time |/api/resource |GET |200 |35 |===
chatGPT ์ ๋ฐ๋ฅธ prettyPrint ์ต์ ์ด ์กด์ฌํ์ง ์์ ๋
// get-endpoint-request.adoc [[ { "uri": "/api/resource", "method": "GET", "status": 200, "responseTime": 35 } ]]
์์ฒ๋ผ, ๋ณด๋ค ๊น๋ํ๊ณ ๋ช ํํ๊ฒ ๋ณผ ์ ์๋ค.
์ด์ ์ค์ ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํด๋ณด์.
๐ ํ ์คํธ ์ฝ๋ ์์ฑ
์์ ์ปจํธ๋กค๋ฌ
@RestController @RequiredArgsConstructor public class CheerTalkController { private final CheerTalkService cheerTalkService; @PostMapping("/cheer-talks") public ResponseEntity<Void> register(@RequestBody @Valid final CheerTalkRequest cheerTalkRequest) { cheerTalkService.register(cheerTalkRequest); return ResponseEntity.ok(null); } }
์ ์ปจํธ๋กค๋ฌ๋ฅผ ํ ์คํธํ๋ ์ฝ๋๋ฅผ ์์ฑํด๋ณด์.
class CheerTalkControllerTest extends DocumentationTest { @Test void ์์ํก์_์ ์ฅํ๋ค() throws Exception { // given CheerTalkRequest request = new CheerTalkRequest("์์ํด์~", 1L); // when ResultActions result = mockMvc.perform(post("/cheer-talks") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ); result.andExpect((status().isOk())) .andDo(RESULT_HANDLER.document( requestFields( fieldWithPath("content").type(JsonFieldType.STRING).description("์์ํก์ ๋ด์ฉ"), fieldWithPath("gameTeamId").type(JsonFieldType.NUMBER).description("์์ํ๋ ๊ฒ์ํ") ) )); } }
ํ ์คํธ์ฉ์ผ๋ก ์ฌ์ฉํ Request DTO ๋ฅผ ์์ฑํ๊ณ , MockMvc ๋ฅผ ์ด์ฉํด์ ์์ฒญ์ ๋ณด๋ธ๋ค.
์๋์ result ์์ document ์ดํ๊ฐ ์ค์ ๋ก ์ค๋ํซ์ ๊ธฐ๋ก๋๋ ๋ถ๋ถ์ด๋ค.
์์ ์ฝ๋์ ๊ฒฝ์ฐ์๋, request body ์ ์ด๋ค ํ๋๊ฐ ๋ด๊ธฐ๋์ง ๊ธฐ๋กํ๋ค.
์ดํ์๋, ํด๋น ํ ์คํธ ์ฝ๋๋ฅผ ๋๋ ค๋ณธ๋ค.
bulid.gradle ์์ ์์ฑํ๋ ๊ฒ์ฒ๋ผ asciidoctor ์ด ๋๋ ค์ ธ buid/generated-snippets ์ ์ค๋ํซ์ด ์์ฑ๋๋ค.
์์ฒ๋ผ request fields, request body ๋ฑ์ด ์์ฑ๋๋ค.
์ด์ ์ ์ง์ ํ ๊ฒ์ฒ๋ผ, ํด๋์ค๋ช /๋ฉ์๋๋ช ์ผ๋ก ๋ง๋ค์ด์ง ๊ฒ์ ํ์ธํ ์ ์๋ค.
์ดํ์๋, ์์ ์ค๋ํซ์ ์ด์ฉํด ๋ช ์ธ์๋ฅผ ์์ฑํ๋ค.
= API ๋ฌธ์ :doctype: book :icons: font :source-highlighter: highlightjs :toc: left :toclevels: 2 :sectlinks: == ์์ํก API === ์์ํก ์ ์ฅ operation::cheer-talk-controller-test/์์ํก์_์ ์ฅํ๋ค[snippets='http-request,request-fields,http-response']
์ดํ bootJar ๋ฅผ ์ด์ฉํด jar ํ์ผ์ ์์ฑํ๋ฉด, build.gradle ์ ์ ์ํ๋๋ก src/main/resources/docs ์ ํด๋น ํ์ผ์ ๋ณต์ ํ๋ค.
src/main/resources/docs ์ api.html ์ด ์์ฑ๋๊ณ , ์ด๋ฅผ Application ์ ๊ฐ๋ํ๋ฉด ํ์ธํ ์ ์๋ค.
'SpringBoot' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ