๐Ÿ’ Spring

Spring-Boot REST Docs ์ ์šฉ๊ธฐ (with. Gradle, Maven)

iseunghan 2022. 7. 4. 21:06
๋ฐ˜์‘ํ˜•

์‹คํ–‰ ํ™˜๊ฒฝ

  • Spring Boot 2.7.1
  • Gradle 7.4.1
  • Maven 3.8.5

๋นŒ๋“œ ์‹œ์Šคํ…œ ์„ค์ •

Gradle

๋”๋ณด๊ธฐ
plugins {
    id 'org.springframework.boot' version '2.7.1'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
    id 'org.asciidoctor.jvm.convert' version '3.3.2' // (1)
}

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

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

ext {
    snippetsDir = file('build/generated-snippets') // (2)
}

test {
    outputs.dir snippetsDir // (3)
    useJUnitPlatform()
}

asciidoctor {
    inputs.dir snippetsDir // (4)
    dependsOn test // (5)
}

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'
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' // (6)
}
  • (1) ํ•ด๋‹น ํ”Œ๋Ÿฌ๊ทธ์ธ์€ .adoc ํŒŒ์ผ ๋ณ€ํ™˜๊ณผ build/ ๋””๋ ‰ํ† ๋ฆฌ์— ๋ณต์‚ฌํ•ด์ฃผ๋Š” ํ”Œ๋Ÿฌ๊ทธ์ธ์ž…๋‹ˆ๋‹ค. Gradle ๋ฒ„์ „ 7 ์ด์ƒ๋ถ€ํ„ฐ 'org.asciidoctor.jvm.convert' ๋กœ ๋ณ€๊ฒฝ๋˜์—ˆ๋‹ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.
  • (2) Snippets ์ƒ์„ฑ์œ„์น˜๋ฅผ ์ „์—ญ ๋ณ€์ˆ˜๋กœ ํ• ๋‹นํ•ฉ๋‹ˆ๋‹ค.
  • (3) test์˜ ์ถœ๋ ฅ ๋””๋ ‰ํ† ๋ฆฌ๋ฅผ Snippets ๋””๋ ‰ํ† ๋ฆฌ๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.
  • (4) ์ž…๋ ฅ ๋””๋ ‰ํ† ๋ฆฌ๋ฅผ Snippets ๋””๋ ‰ํ† ๋ฆฌ๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.
    • (5) ํ•ด๋‹น ์ž‘์—…์€ test → asciidoctor ์ˆœ์œผ๋กœ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค.
  • (6) test-scope mockMvc์— ์˜์กด์„ฑ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

 

Maven

๋”๋ณด๊ธฐ
<!-- (1) -->
<dependency>
    <groupId>org.springframework.restdocs</groupId>
    <artifactId>spring-restdocs-mockmvc</artifactId>
    <scope>test</scope>
</dependency>

<!-- REST DOCS -->
<!-- asciidoctor ํ”Œ๋Ÿฌ๊ทธ์ธ ์ถ”๊ฐ€ -->
<!-- (2) -->
<build>
    <plugins>
            <plugin>
                <groupId>org.asciidoctor</groupId>
                <artifactId>asciidoctor-maven-plugin</artifactId>
                <version>2.2.2</version>
                <executions>
                    <execution>
                        <id>generate-docs</id>
                        <!-- (3) -->
                        <phase>prepare-package</phase>
                        <goals>
                            <goal>process-asciidoc</goal>
                        </goals>
                        <configuration>
                            <backend>html</backend>
                            <doctype>book</doctype>
                        </configuration>
                    </execution>
                </executions>
                <dependencies>
                    <!-- (4) -->
                    <dependency>
                        <groupId>org.springframework.restdocs</groupId>
                        <artifactId>spring-restdocs-asciidoctor</artifactId>
                        <version>2.0.4.RELEASE</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
</build>
  • (1) Test scope RestDocs MockMvc๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
  • (2) asciidoctor ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
  • (3) prepare-package ์˜ต์…˜์€ ํŒจํ‚ค์ง€๋‚ด์— API ๋ฌธ์„œ๋ฅผ ํฌํ•จํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.
  • (4) asciidoctor์˜ ์˜์กด์„ฑ์œผ๋กœ spring-restdocs-asciidoctor ์ถ”๊ฐ€ .adoc ํŒŒ์ผ์ด target/generated-snippets ์•„๋ž˜์— ์ƒ์„ฑ๋œ Snippets ์„ ๊ฐ€๋ฆฌํ‚ค๋Š” ์„ค์ •์ด ์ถ”๊ฐ€๋œ๋‹ค.

 

๋ฌธ์„œ ํŒจํ‚ค์ง• (Packaging the Documentation)

Maven

๋”๋ณด๊ธฐ
<plugin> 
    <groupId>org.asciidoctor</groupId>
    <artifactId>asciidoctor-maven-plugin</artifactId>
    <!-- ์ƒ๋žต.. -->
</plugin>
<!-- (1) -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-resources-plugin</artifactId>
    <version>3.2.0</version>
    <executions>
        <execution>
            <id>copy-resources</id>
            <phase>prepare-package</phase>
            <goals>
                <goal>copy-resources</goal>
            </goals>
            <configuration>
                <!-- (2) -->
                <outputDirectory>
                    ${project.build.outputDirectory}/static/docs
                </outputDirectory>
                <resources>
                    <resource>
                        <directory>
                            ${project.build.directory}/generated-docs
                        </directory>
                    </resource>
                </resources>
            </configuration>
        </execution>
    </executions>
</plugin>
  • (1) ๋ฌธ์„œ๊ฐ€ ํŒจํ‚ค์ง€๋กœ ๋ณต์‚ฌ๋˜๊ธฐ ์ „์— ์ƒ์„ฑ๋˜์•ผ ํ•˜๋ฏ€๋กœ ์œ„์— Asciidoctor ํ”Œ๋Ÿฌ๊ทธ์ธ ๋’ค์— ์„ ์–ธํ•ฉ๋‹ˆ๋‹ค.
  • (2) generated-docs/index.html์„ static/docs ์— ๋ณต์‚ฌ

 

Gradle

๋”๋ณด๊ธฐ
bootJar {
    dependsOn asciidoctor // (1)
    copy { // (2)
        from "${asciidoctor.outputDir}"
        into 'src/main/resources/static/docs'
    }
}
  • (1) ํ•ด๋‹น ์ž‘์—…์€ asciidoctor → bootJar ์ˆœ์œผ๋กœ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค.
  • (2) asciidoctor๊ฐ€ ์ƒ์„ฑํ•ด์ค€ build/docs/asciidoc์— ์žˆ๋Š” HTML ํŒŒ์ผ์ด src/main/resources/static/docs ๋””๋ ‰ํ† ๋ฆฌ์— ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. static ์•„๋ž˜์— ์žˆ๋Š” ํŒŒ์ผ๋“ค์€ ํ†ฐ์บฃ์ด ์ž๋™์œผ๋กœ ํ˜ธ์ŠคํŒ…์„ ํ•ด์ฃผ๊ธฐ ๋•Œ๋ฌธ์— http://localhost:8080/docs/index.html ๋กœ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

TestSupport ํด๋ž˜์Šค ์ƒ์„ฑ

๊ธฐ๋ณธ ๊ณตํ†ต ์„ค์ •๋“ค์„ ๋”ฐ๋กœ ํด๋ž˜์Šค์— ๋นผ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

์—ฌ๊ธฐ์„œ ์„ค์ •์€ ๋””๋ ‰ํ† ๋ฆฌ๊ฐ€ ํด๋ž˜์Šค๋ช…/๋ฉ”์†Œ๋“œ๋ช… ์œผ๋กœ ํ˜•์„ฑ์ด ๋ฉ๋‹ˆ๋‹ค.

@ExtendWith(RestDocumentationExtension.class) ๋ฅผ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

• RestDocumentationExtension ์€ Maven ์˜ ๊ฒฝ์šฐ “target/generated-snippets” Gradle ์˜ ๊ฒฝ์šฐ “build/generated-snippets” ๋ฅผ ์ž๋™์œผ๋กœ ์ถœ๋ ฅ ๋””๋ ‰ํ† ๋ฆฌ๋กœ ์„ค์ •๋˜์–ด์žˆ๋‹ค.

import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;

@ExtendWith(RestDocumentationExtension.class)
public class TestSupport {

    protected MockMvc mockMvc;

    @BeforeEach
    void setUp(WebApplicationContext context,
               RestDocumentationContextProvider provider) {

        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(MockMvcRestDocumentation.documentationConfiguration(provider))
                .alwaysDo(print())
                .alwaysDo(document("{class-name}/{method-name}",
                                preprocessRequest(prettyPrint()),
                                preprocessResponse(prettyPrint())
                        )
                )
                .build();
    }
}

์œ„ ์„ค์ • ์–ด๋…ธํ…Œ์ด์…˜์œผ๋กœ ํ•œ๋ฐฉ์— ์„ค์ • ์ ์šฉํ•˜๊ธฐ!

๋”๋ณด๊ธฐ
    @AutoConfigureMockMvc    // --> webAppContextSetup(webApplicationContext)
    @AutoConfigureRestDocs   // --> apply(MockMvcRestDocumentation.documentationConfigration(restDocumentationContextpProvider)

 

REFERENCE

 

get, post ํŒจํ‚ค์ง€ ๋ณ€๊ฒฝ

๊ธฐ์กด ์‚ฌ์šฉ์ค‘์ด๋˜ MockMvcRequestBuilders์—์„œ RestDocumentationRequestBuilders๋กœ ๋ณ€๊ฒฝ

//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

/* 
    ๊ธฐ์กด MockMvcRequestBuilders ํŒจํ‚ค์ง€์—์„œ RestDocumentationRequestBuilders๋กœ ๋ณ€๊ฒฝ! 
*/

import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;

์ž ์ด์ œ ๋ชจ๋“  ์ค€๋น„๊ฐ€ ๋๋‚ฌ์Šต๋‹ˆ๋‹ค. ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•˜์—ฌ ๋ฌธ์„œ๋ฅผ ๋งŒ๋“ค์–ด ๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ ์ž‘์„ฑ

REST Docs๋ฅผ ์ƒ์„ฑํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๊ตณ์ด @SpringBootTest ๋ฅผ ์ง„ํ–‰ํ•  ์ด์œ ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

์‹œ๊ฐ„๋„ ์˜ค๋ž˜ ๊ฑธ๋ฆฌ๊ณ  ๋ชจ๋“  ๋นˆ๋“ค์ด ์˜ฌ๋ผ๊ฐ€๊ธฐ ๋•Œ๋ฌธ์— ์‹ ๊ฒฝ์จ์ค˜์•ผ ํ•  ๋ถ€๋ถ„๋“ค์ด ๋งŽ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๊ทธ๋ž˜์„œ ๋Œ€๋ถ€๋ถ„ WebMvcTest ํ•„์š”ํ•œ ์˜์กด์„ฑ๋“ค์„ Mocking ํ•˜์—ฌ ํ…Œ์ŠคํŠธ๋ฅผ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค.

์•„๋ž˜๋Š” controller ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค.

@WebMvcTest(controller = UserTodoListApiController.class)
public class UserAccountApiDocsTest extends RestDocumentSupport {

    @MockBean
    private AccountService accountService;

    @Test
    @WithMockUser
    void retrieve_My_Account() throws Exception {
        // given
        AccountDto accountDto = AccountDto.builder()
                .id(5L)
                .username("test")
                .password("pass")
                .email("test@email.com")
                .nickname("nickname")
                .roles("USER")
                .todoList(null)
                .build();
        given(accountService.findMyAccount(anyString())).willReturn(accountDto);

        // when & then
        mockMvc.perform(get("/user/accounts/{username}", "john1234")
                        .header("Authorization", "testToken"))
                .andDo(print())
                .andDo(document("{class-name}/{method-name}",
                                requestHeaders( // (1)
                                        headerWithName("Authorization").description("JWT ํ† ํฐ ๊ฐ’")
                                ),
                                pathParameters( // (2)
                                        parameterWithName("username").description("์‚ฌ์šฉ์ž ์•„์ด๋””")
                                ),
                                responseFields( // (3)
                                        fieldWithPath("id").description("idx"),
                                        fieldWithPath("username").description("์•„์ด๋””"),
                                        fieldWithPath("password").description("ํŒจ์Šค์›Œ๋“œ"),
                                        fieldWithPath("nickname").description("๋‹‰๋„ค์ž„"),
                                        fieldWithPath("email").description("์ด๋ฉ”์ผ"),
                                        fieldWithPath("roles").description("๊ถŒํ•œ"),
                                        fieldWithPath("todoList").ignored()
                                )
                        )
                )
                .andExpect(status().isOk())
        ;
    }

๋ฌธ์„œํ™”๋ฅผ ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์š”์ฒญ๊ณผ ์‘๋‹ต์— ํฌํ•จ๋œ ํ—ค๋”, ๋ฐ”๋”” ๋“ฑ๋“ฑ์— ํฌํ•จ๋œ ๊ฐ’๋“ค์„ ๋ช…์„ธํ•ด์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค.

  • (1) Request Header์— ๋“ค์–ด๊ฐ€๋Š” ๊ฐ’์„ ๋ช…์„ธํ•ฉ๋‹ˆ๋‹ค.
    • header๊ฐ’์ด๊ธฐ ๋•Œ๋ฌธ์— headerWithName์œผ๋กœ ๋ช…์„ธํ•ฉ๋‹ˆ๋‹ค.
  • (2) Path Parameter์— ๋“ค์–ด๊ฐ€๋Š” ๊ฐ’์„ ๋ช…์„ธํ•ฉ๋‹ˆ๋‹ค.
    • Parameter์ด๊ธฐ ๋•Œ๋ฌธ์— parameterWithName์œผ๋กœ ๋ช…์„ธํ•ฉ๋‹ˆ๋‹ค.
  • (3) ResponseFields, ์ฆ‰ ResponseBody์— ์žˆ๋Š” ํ•„๋“œ๊ฐ’์— ๋Œ€ํ•œ ๋ช…์„ธํ•ฉ๋‹ˆ๋‹ค.
    • ํ•„๋“œ๋Š” fieldWithPath๋กœ ์ง€์ •ํ•  ์ˆ˜ ์žˆ๊ณ , ์„ค๋ช… ๋˜๋Š” ํƒ€์ž… ๋˜๋Š” optional ์—ฌ๋ถ€๋„ ์ง€์ •ํ•ด์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ž์„ธํ•œ ์‚ฌํ•ญ์€ ๊ณต์‹๋ฌธ์„œ๋ฅผ ํ™•์ธํ•˜์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค.

๋นŒ๋“œ ๋ฐ Snippet ํ™•์ธ

ํ…Œ์ŠคํŠธ๊ฐ€ ํ†ต๊ณผ๊ฐ€ ๋˜๋ฉด, Maven ์˜ ๊ฒฝ์šฐ target/generated-snippets Gradle ์˜ ๊ฒฝ์šฐ build/generated-snippets ์•„๋ž˜์— snippets์ด ์ƒ๊ธฐ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

๊ธฐ๋ณธ์ ์œผ๋กœ ์ƒ์„ฑ๋˜๋Š” snippets์€ ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.

  • <output-directory>/index/curl-request.adoc
  • <output-directory>/index/http-request.adoc
  • <output-directory>/index/http-response.adoc
  • <output-directory>/index/httpie-request.adoc
  • <output-directory>/index/request-body.adoc
  • <output-directory>/index/response-body.adoc

๋งŒ๋“ค์–ด์ง„ Snippet ๋“ค์„ ์—ฐ๊ฒฐํ•ด์ค„ ์‚ฌ์šฉ์ž ์ •์˜ .adoc ํŒŒ์ผ์„ src/docs/asciidoc ๊ฒฝ๋กœ์— ๋งŒ๋“ค๊ณ  ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. (Asciidoctor User Manual ์ฐธ๊ณ )

.adoc ์ƒ์„ฑ

== Feature 1 REST API
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 4
:sectlinks:

=== API ๊ธฐ๋Šฅ๋ช…
==== Request ์š”์ฒญ
include::{snippets}/className/methodName/http-request.adoc[]
include::{snippets}/className/methodName/request-headers.adoc[]
include::{snippets}/className/methodName/path-parameters.adoc[]
include::{snippets}/className/methodName/request-body.adoc[]
include::{snippets}/className/methodName/request-fields.adoc[]

==== Response ์‘๋‹ต
include::{snippets}/className/methodName/http-response.adoc[]
include::{snippets}/className/methodName/response-body.adoc[]
include::{snippets}/className/methodName/response-fields.adoc[]
  • toc:left : Table Of Content๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์œ„์น˜๋ฅผ ์™ผ์ชฝ์œผ๋กœ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.
  • toclevels : toc์˜ ๊นŠ์ด๋ฅผ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. h1~h4๊นŒ์ง€ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.

์ด์ œ API ๋ณ„๋กœ .adoc์„ ์ƒ์„ฑํ–ˆ๋‹ค๋ฉด, ์ตœ์ข… API ๋ฌธ์„œ๋ฅผ ์œ„ํ•ด index.adoc์„ ์ƒ์„ฑํ•ด์ค๋‹ˆ๋‹ค.

index.adoc ์ƒ์„ฑ

asciidoctor ํ”Œ๋Ÿฌ๊ทธ์ธ์€ index.adoc์ด๋ผ๋Š” ํŒŒ์ผ์„ index.html์œผ๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ jar ๋‚ด๋ถ€์— ์œ„์น˜์‹œ์ผœ์ค๋‹ˆ๋‹ค.

= MyApp REST API
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 4
:sectlinks:

// (1)
include::{docdir}/Feature_1_Api.adoc[]

include::{docdir}/Feature_2_Api.adoc[]

include::{docdir}/Feature_3_Api.adoc[]
  • (1) ๊ธฐ๋Šฅ๋ณ„๋กœ ์ƒ์„ฑํ•ด์ค€ .adoc ํŒŒ์ผ๋“ค์„ ์ ์–ด์ค๋‹ˆ๋‹ค.

๋ฌธ์„œ ํŒจํ‚ค์ง•

  • ์ƒ์„ฑ๋œ Snippets

  • src/docs/asciidoc ์ƒ์„ฑ๋œ .adoc ํŒŒ์ผ๋“ค

  • Maven : clean → package ์ˆœ์œผ๋กœ ์ง„ํ–‰
  • Gradle : clean → build ์ˆœ์œผ๋กœ ์ง„ํ–‰

include file not found.. ์—๋Ÿฌ ๋ฐœ์ƒ..?

Task :asciidoctor
include file not found: /Users/iseunghan/workspaces/study/test-rest-docs-gradle/{snippets}/user-todo-list-api-controller-test/create_-todo/http-request.adoc …

index.adoc ์ƒ๋‹จ์— ์•„๋ž˜ ์ฝ”๋“œ ์ถ”๊ฐ€!

ifndef::snippets[]
:snippets: ../../build/generated-snippets
endif::[]

Snippets์˜ ๊ฒฝ๋กœ๋ฅผ ์ฐพ์ง€ ๋ชปํ•˜๋Š” ๊ฒƒ ๊ฐ™์€๋ฐ static/docs/index.html ๊ธฐ์ค€์œผ๋กœ Snippets์˜ ์ƒ๋Œ€๊ฒฝ๋กœ๋ฅผ ์ง€์ •ํ•ด์ฃผ๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์„ฑ๊ณต!

์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ index.html

์ด์ œ localhost:8080/docs/index.html ๋กœ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋ชจ๋“  ์ฝ”๋“œ๋Š” Github์— ์žˆ์œผ๋‹ˆ ์ฐธ๊ณ ํ•˜์‹œ๊ธธ ๋ฐ”๋ž๋‹ˆ๋‹ค. ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค

REFERENCES

๋ฐ˜์‘ํ˜•