[SpringBoot] Spring Rest Docs ์ ์ฉ (gradle 7๋ฒ์ )
์ธํผ์์ ์งํํ ๊ณตํตํ๋ก์ ํธ ๋ API ๋ช ์ธ์๋ก Swagger๊ฐ ์๋ Spring Rest Docs๋ฅผ ์ฑํํ๋ค. ๊ทธ ๋ ์ ์ฉํ๋ ๋ฒ์ ๋ ธ์ ์ ๋์ถฉ ์ ์ด๋จ๋๋ฐ ์ด๋ฒ์ ์ ์ฉํ๋ฉด์ ๊น๋ํ๊ฒ(?) ์ ๋ฆฌํด๋ณด๋ ค๊ณ ํ๋ค.
๐๐ป ๋ด๊ฐ ์๊ฐํ์ ๋ Spring Rest Docs์ ์ฅ์
- ํ ์คํธ๊ฐ ์ฑ๊ณตํด์ผ ๋ฌธ์ํ๊ฐ ๋๊ธฐ๋๋ฌธ์ ํญ์ ์ต์ ํ ๋์์๊ณ ํ์ง์ ๋ณด์ฅํ ์ ์๋ค.
- ๋ฌธ์ํ ๋์ ๋ Swagger๋ณด๋ค๋ ํ๋ก ํธ๊ฐ ์ฝ๊ฒ ์ดํดํ ์ ์๋ค๊ณ ์๊ฐํ๋ค.
- Request, Response์ ๊ดํ ์ค๋ช ์ ์ด๋ ธํ ์ด์ ์ด ์๋ ํ ์คํธ ์ ์์ฑํ ์ ์์ด spring ์๋ฒ์ ์ฝ๋๊ฐ ์ข ๋ ๊น๋ํด๋ณด์ธ๋ค.
๐๐ป Spring Rest Docs์ ๋จ์
- ํ ์คํธ ์ฝ๋ ์ง๋๊ฒ ๋๋ฌด๋๋ฌด๋๋ฌด ๊ท์ฐฎ๋ค... ์ ๋ง๋ก...
๋จ์ ์ ์ ๊ทน๋ณตํ๋ฉด ๋ฟ๋ฏํ ๊ฒฐ๊ณผ๋ฌผ์ด ๋์จ๋ค...ํํ...
๐ ๋์ ๊ฐ๋ฐ ํ๊ฒฝ
- Spring Boot 2.7.3
- Gradle 7.x
- Java 8
โ ๏ธ Spring Rest Docs ์ ์ฉํ๊ธฐ ์ ์ ๊ผญ ํ์ธํด ๋ด์ผ ํ ๊ฒ : Gradle ๋ฒ์ ์ด๋ค.
gradle์ ๋ฒ์ ์ ๋ฐ๋ผ Spring Rest Docs ์ ์ฉ๋ฒ์ด ๋ฌ๋ผ์ง๋ค!!! ํ๋ฌ๊ทธ์ธ๋ถํฐ snippet ์์ฑ ๊ฒฝ๋ก๊น์ง ์ด๋ง๋ฌด์ํ๊ฒ ๋ค๋ฅด๋๊น ๊ผญ ํ์ธํ๋๊ฒ ์ข๋ค...
๋จผ์ gradle/wrapper/gradle-wrapper.properties ๋ฅผ ๋ค์ด๊ฐ๋ค.
์ด๊ฒ ๋ณด์ด๋ ๊ณณ์์ gradle ๋ฒ์ ์ ํ์ธํ ์ ์๋ค. ๋๋ 7 ๋ฒ์ ์ ์ฌ์ฉ ์ค์ด๋ค!
โ๏ธ gradle์ ๋ฒ์ ์ด 7 ์ด์์ด๋ฉด org.asciidoctor.jvm.convert ๋ผ๋ ๊ฒ์ ์ฌ์ฉํ๋ค.
gradle 6 ๋ฒ์ ์ ๊ตฌ๊ธ๋งํ๋ฉด ์์ฒญ๋์ค๊ธฐ ๋๋ฌธ์ ์์ฐ๋๋ก ํ๊ฒ ๋ค...(๊ท์ฐฎ์์๊ฐ ์ ๋ ์๋...ใ )
๐ฉ๐ป๐ป build.gradle
ํ๋ฌ๊ทธ์ธ ๋ฐ ์์กด์ฑ ์ถ๊ฐ
plugins {
...
id 'org.asciidoctor.jvm.convert' version '3.3.2'
...
}
dependencies {
...
// spring rest docs
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
...
}
configurations {
asciidoctorExtensions
}
snippet ์์ฑ ๋๋ ํ ๋ฆฌ ์ค์
ext {
snippetsDir = file('build/generated-snippets')
}
test {
outputs.dir snippetsDir
finalizedBy 'asciidoctor'
}
tasks.named('test') {
useJUnitPlatform()
}
asciidoctor ์ค์
asciidoctor {
inputs.dir snippetsDir
configurations 'asciidoctorExtensions'
dependsOn test
}
asciidoctor.doFirst {
delete file('src/main/resources/static/docs')
}
bootJar ์ค์
bootJar {
dependsOn asciidoctor
copy {
from "build/docs/asciidoc"
into "src/main/resources/static/docs"
}
}
task copyDocument(type: Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static/docs")
}
build {
dependsOn copyDocument
}
์ด๋ ๊ฒ ์ถ๊ฐํ๋ฉด ์๋ฃ!!
๐ฉ๐ป๐ป ํ ์คํธ ์ฝ๋ ์์ฑ
๋๋ mockMvc ๊ธฐ๋ฐ์ผ๋ก ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํ์๋ค.
mockMvc๋ก ํ ์คํธ ์ฝ๋ ์์ฑ ์ @AutoConfigureMockMvc์ @AutoConfigureRestDocs ์ด๋ ธํ ์ด์ ์ ๊ผญ(?) ๋ฌ์์ผ ํ๋ค.
ํ ... ๊ทผ๋ฐ ์ด๋ฒ ํ๋ก์ ํธ์์ @AutoConfigureMockMvc ์ด๋ ธํ ์ด์ ์ถ๊ฐ๋ฅผ ์ํ๋๋ฐ ์ฑ๊ณตํ๋ค..... ์์ง....(?)
์๋๋ ์ด๋ฒ ํนํํ๋ก์ ํธ์์ ๋ฌธ์ํํ API ์ค ํ๋์ด๋ค.
Given-When-Then ํจํด์ผ๋ก ์์ฑํด๋ณด์๋ค.
@WebMvcTest(YoutuberApiController.class)
@AutoConfigureRestDocs
public class YoutuberRestDocsTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private YoutuberService youtuberService;
@Test
public void youtuber_๊ฒ์() throws Exception {
// given
String request = "์ผ์ฑ์ฒญ๋
";
List<YoutuberResponseDto> response = new ArrayList<>();
response.add(YoutuberResponseDto.builder()
.channelId("UC_XI3ByFO1uZIIH-g-zJZiw")
.channelName("์ผ์ฑ์ฒญ๋
SW์์นด๋ฐ๋ฏธ Youtube์ฑ๋ HELLOSSAFY")
.thumbnail("https://yt3.ggpht.com/ytc/AMLnZu9qdR9T9_9OXz27_3lZVs4hfwECef2oUSylrcQv=s800-c-k-c0x00ffffff-no-rj").build());
given(youtuberService.searchYoutuber(anyString())).willReturn(response);
// when, then
mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/youtubers/{keyword}", request))
.andExpect(status().isOk())
.andDo(document("youtuber-search",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
pathParameters(
parameterWithName("keyword").description("๊ฒ์ํ ๋จ์ด")
),
responseFields(
fieldWithPath("success").description("API ์์ฒญ ์ฑ๊ณต ์ฌ๋ถ").type(JsonFieldType.BOOLEAN),
fieldWithPath("data").description("Response data").type(JsonFieldType.ARRAY)
).andWithPrefix("data[].",
fieldWithPath("channelId").description("์ ํ๋ฒ ์ฑ๋ id").type(JsonFieldType.STRING),
fieldWithPath("channelName").description("์ ํ๋ฒ ์ฑ๋๋ช
").type(JsonFieldType.STRING),
fieldWithPath("thumbnail").description("์ ํ๋ฒ ์ธ๋ค์ผ ์ฃผ์").type(JsonFieldType.STRING))
));
}
}
given
๋จผ์ API request๋ฅผ ํ๋ ๋ง๋ค์ด์ฃผ๊ณ ๊ทธ request์ ๋ง๋ response๋ ์์ฑํด์ค๋ค.
๊ทธ๋ฆฌ๊ณ Mockito์ given().willReturn()์ ์ด์ฉํด์ request์ response๋ฅผ ์ ํด๋๋๋ค.
Service ๋ก์ง ๊ฒ์ฆ์ด ํ์ํ๊ฒ ์๋๋ผ ๋ฌธ์ํ๊ฐ ๋ชฉํ์ด๊ธฐ ๋๋ฌธ์ ์ด๋ ๊ฒ ํ๋ ๊ฒ ๊ฐ๋ค...!
์ ๋ง ๊ฐ๋จํ mockito ์์๋ณด๊ธฐ
๐ค given(A).willReturn(B)?
A๋ผ๋ ์๋น์ค๊ฐ ํธ์ถ๋๋ฉด ๊ทธ ๊ฒฐ๊ณผ๋ ๋ฌด์กฐ๊ฑด B๋ผ๊ณ ์ค์ (?)ํด๋๋ ๊ฒ์ด๋ค.
๐ค given().willReturn()๊ณผ when().thenReturn()์ ์ฐจ์ด?
๋์์ ๋๊ฐ๋ค. BDD๊ธฐ๋ฐ์ผ๋ก ํ ์คํธํ ๊ฒฝ์ฐ //given ์ ์์ when()์ ์ฌ์ฉํ๋ฉด ํ๋ฆ์ด ์ด์ํ๋ค๊ณ ์๊ฐํ ๊น๋ด given()์ด ์๊ฒผ๋จ๋ค.
๊ทธ๋์ ๊ทธ๋ฅ given()์ ์ผ๋ค.
- import static org.mockito.**Mockito**.*when*
- import static org.mockito.**BDDMockito**.*given*;
// given
String request = "์ผ์ฑ์ฒญ๋
";
List<YoutuberResponseDto> response = new ArrayList<>();
response.add(YoutuberResponseDto.builder()
.channelId("UC_XI3ByFO1uZIIH-g-zJZiw")
.channelName("์ผ์ฑ์ฒญ๋
SW์์นด๋ฐ๋ฏธ Youtube์ฑ๋ HELLOSSAFY")
.thumbnail("https://yt3.ggpht.com/ytc/AMLnZu9qdR9T9_9OXz27_3lZVs4hfwECef2oUSylrcQv=s800-c-k-c0x00ffffff-no-rj").build());
given(youtuberService.searchYoutuber(anyString())).willReturn(response);
when + then
๋ณ์ ์์ฑํ๋๊ฒ ๊ท์ฐฎ์์ ํฉ์ณ๋ฒ๋ ธ๋ค...ใ ใ
mockMvc๋ก perform์ ์ํํ๋๋ฐ ์ด ๋ MockMvcRequestBuilders์ด ์๋RestDocumentationRequestBuilders์ ๋ฉ์๋๋ฅผ ์ฌ์ฉํ์!!!
MockMvcRequestBuilders์ ๋ฉ์๋๋ค์ Path variables๋ฅผ ์ฌ์ฉํ๋ api์ ๋ํด ๋ฌธ์ํ๊ฐ ๋ถ๊ฐ๋ฅํ๋ค....
๊ทธ๋ฅ ์ฒ์๋ถํฐ ๋ฌด์กฐ๊ฑด RestDocumentationRequestBuilders๋ฅผ ์ฌ์ฉํ๊ธธ!!!!!!
// when, then
mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/youtubers/{keyword}", request))
.andExpect(status().isOk())
// -------------------------------------------------------์ฌ๊ธฐ์๋ถํฐ then
.andDo(document("youtuber-search",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
pathParameters(
parameterWithName("keyword").description("๊ฒ์ํ ๋จ์ด")
),
responseFields(
fieldWithPath("success").description("API ์์ฒญ ์ฑ๊ณต ์ฌ๋ถ").type(JsonFieldType.BOOLEAN),
fieldWithPath("data").description("Response data").type(JsonFieldType.ARRAY)
).andWithPrefix("data[].",
fieldWithPath("channelId").description("์ ํ๋ฒ ์ฑ๋ id").type(JsonFieldType.STRING),
fieldWithPath("channelName").description("์ ํ๋ฒ ์ฑ๋๋ช
").type(JsonFieldType.STRING),
fieldWithPath("thumbnail").description("์ ํ๋ฒ ์ธ๋ค์ผ ์ฃผ์").type(JsonFieldType.STRING))
));
์ฝ๋์ ๋ํ ์งง์ ์ค๋ช ์ ์ถ๊ฐํด๋ดค๋ค... ๋ ์์ธํ๊ฑด ์ญ์ ๊ตฌ๊ธ๋ง๐
document("youtuber-search", ...)
๋ฌธ์ํํ document์ ํด๋ ์ด๋ฆ์ ๋ฃ์ด์ฃผ๋ฉด ๋๋ค.
API๊ฐ ์ฌ๋ฌ๊ฐ ์กด์ฌํ๋ฉด ๊ตฌ๋ถํ ์ ์๊ฒ ๊ธฐ๋ฅ์ ๋ํ ์ค๋ช ์ ์จ๋๋๊ฒ ์ข์ ๊ฒ ๊ฐ๋ค.(๊ทธ๋ฅ ๋ด์๊ฐใ )
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
๊ฒฐ๊ณผ๋ฌผ์์ request์ response๋ฅผ ๋ณด๊ธฐ ํธํ ํ์์ผ๋ก ์ถ๋ ฅํด์ฃผ๋ ์ฉ๋์ด๋ค.
์ด๊ฑธ ์ํด์ฃผ๋ฉด ํ ์ค๋ก ์ซ๋ผ๋ฝ ๋์ค๊ธฐ ๋๋ฌธ์ ๋ฌด์กฐ๊ฑด ํ๋ ๊ฑธ ์ถ์ฒ
pathParameters(
parameterWithName("keyword").description("๊ฒ์ํ ๋จ์ด")
),
request์ path parameter๊ฐ ์กด์ฌํ๋ฉด ์ถ๊ฐํ๋ฉด๋๋ค.
์ฌ๋ฌ ๊ฐ์ผ ๊ฒฝ์ฐ parameterWithName()์ ์ถ๊ฐํ์.
requestFields(
fieldWithPath("stuffName").description("stuff ์๋ฌธ๋ช "),
fieldWithPath("stuffNameKor").description("stuff ํ๊ธ๋ช ")
),
์ด๊ฑด ๋ค๋ฅธ ํ๋ก์ ํธ์์ ๊ฐ์ ธ์จ ์ฝ๋์ธ๋ฐ
request์ body๊ฐ ์กด์ฌํ๋ฉด ์ด๋ ๊ฒ ์ฌ์ฉํ๋ฉด ๋๋ค!
responseFields(
fieldWithPath("success").description("API ์์ฒญ ์ฑ๊ณต ์ฌ๋ถ").type(JsonFieldType.BOOLEAN),
fieldWithPath("data").description("Response data").type(JsonFieldType.ARRAY)
)
response์ ๋ค์ด๊ฐ๋ ๋ฐ์ดํฐ์ ๋ํ ์ค๋ช ์ ์ฐ๋ ๊ณณ์ด๋ค.
responseFields๋ฅผ ์ฌ์ฉํ๋ฉด response ๋ฐ์ดํฐ์ ๋ํ ์ค๋ช ์ ๋ฌด์กฐ๊ฑด ๋ค ์ถ๊ฐํด์ผํ๊ณ , relaxedResponseFields์๋ ์ํผ ์ด๊ฑธ ์ฌ์ฉํ๋ฉด ์ํ๋ ๋ฐ์ดํฐ๋ง ์ถ๊ฐํ ์ ์๋ค.
.andWithPrefix("data[].",
fieldWithPath("channelId").description("์ ํ๋ฒ ์ฑ๋ id").type(JsonFieldType.STRING),
fieldWithPath("channelName").description("์ ํ๋ฒ ์ฑ๋๋ช
").type(JsonFieldType.STRING),
fieldWithPath("thumbnail").description("์ ํ๋ฒ ์ธ๋ค์ผ ์ฃผ์").type(JsonFieldType.STRING))
)
์ API ์๋ต์์ data ๋ณ์์ ์ค์ง์ ์ธ ๋ฐ์ดํฐ๊ฐ ๋ด๊ฒจ์์ด์ .andWithPrefix๋ก data ์์ ๋ด๊ฒจ์๋ ์ ๋ณด๋ค์ ๋ํ ์ค๋ช ์ ์ถ๊ฐํด์ฃผ์๋ค
๐ฉ๐ป๐ป HTML ๋ฌธ์ ๋ง๋ค๊ธฐ
ํ ์คํธ ์ฝ๋๊ฐ ์ฑ๊ณต์ ์ผ๋ก ๋๋๋ค๋ฉด, build/generated-snippets์ youtuber-search ํด๋๊ฐ ์๊ธด๋ค.
์ด๊ฑธ ๊ธฐ๋ฐ์ผ๋ก ๋ชจ๋ API ์ค๋ช ์ด ๋ค์ด๊ฐ adoc ํ์ผ์ ๋ง๋ค ๊ฒ์ด๋ค.
๋จผ์ /src/docs/asciidoc ํด๋์ index.adoc์ ์์ฑํ๋ค.
์๋์ ๊ฐ์ด ํด๋๋ณ๋ก ์ถ๊ฐํด์ฃผ๋ฉด ๋๋์ด ๋์ด ๋ณด์ธ๋ค....
[[Youtuber-๊ฒ์]]
== Youtuber ๊ฒ์
=== Http Request
include::{snippets}/youtuber-search/http-request.adoc[]
=== Path parameters
include::{snippets}/youtuber-search/path-parameters.adoc[]
=== Http Response
include::{snippets}/youtuber-search/http-response.adoc[]
=== Response fields
include::{snippets}/youtuber-search/response-fields.adoc[]
๐ฅน ์์ฑ
./gradlew build๋ฅผ ํ๊ณ ์๋ฒ๋ฅผ ์คํํ๋ฉด localhost:8080/docs/index.html์ ์ ์์ด ๊ฐ๋ฅํ๊ณ ๊ณ ์ํ๋ ๋์ ๊ฒฐ๊ณผ๋ฌผ์ด ๋ณด์ธ๋ค...ํํ
๊ณตํต ํ๋ก์ ํธ ๋ 15์๊ฐ ๊ฑธ๋ ค์ ๋ง๋ ๋ช ์ธ์....ํํ....
'Spring Boot' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[Kotlin+SpringBoot] Kotest ์ฌ์ฉ ์ @Autowired ์ค๋ฅ (0) | 2023.03.16 |
---|---|
[Servlet/JSP] ์ดํด๋ฆฝ์ค Dynamic Web Project ์์ฑ ๋ฐ ๊ตฌ์กฐ ํ์ธ (0) | 2022.03.25 |
Web Architecture์ ๋ํ ์ดํด (0) | 2022.03.25 |