Post

Spring Rest Docs 시작하기

프로젝트를 진행하면서 API를 자동으로 문서화할 수 있지 않을까 고민하였다. 그동안 노션에 직접 적고 있었는데 직접 관리를 해줘야한다는 단점이 있었다. 별거 아닐 수 있지만 꽤나 큰 단점이다. 직접 시간을 들여 작성을 해야하고, 오타나 실수로 누락되는 부분이 있을 수 있기 때문이다. 나는 덜렁거리는 성격이라 깜빡하는 일이 많기에 해결 방법으로 자동으로 문서화하도록 전환하기로 하였다.

노션에 정리된 api 문서

API 자동 문서화를 찾아본 결과, Swagger와 Spring Rest Docs를 찾을 수 있었다.


Spring Rest Docs와 Swagger 비교

Spring REST Docs는 테스트 코드 기반으로 RESTful 문서 생성을 도와주는 도구이다.

 Spring Rest DocsSwagger
장점제품코드에 영향 없다.API 를 테스트 해 볼수 있는 화면을 제공한다.
 테스트가 성공해야 문서작성된다.적용하기 쉽다.
단점적용하기 어렵다.제품코드에 어노테이션 추가해야한다.
 제품코드와 동기화가 안될수 있다. 

참고: 우아한 테크 블로그

원래 쉽고 빠르게 Swagger를 사용하려 하였으나 제품코드와 동기화가 안될 수 있다는 단점과 Spring REST Docs는 테스트 코드를 통과한 API만 문서에 반영준다는 점으로 Spring Rest Docs를 사용하기로 결정하였다.


Project Spec

StackInfo
SpringBoot3.2.1
Java17
Gradle8.5


Spring Rest Docs 적용하기

build.gradle은 공식 문서를 찾아 적용하였다.

내가 적용한 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
71
72
73
74
75
buildscript {
    ext {
        snippetsDir = file('build/generated-snippets')
    }
}

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.1'
    id 'io.spring.dependency-management' version '1.1.4'
    id "org.asciidoctor.jvm.convert" version "3.3.2"
}

configurations {
    asciidoctorExt
}

group = 'toyproject.genshin'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

repositories {
    mavenCentral()
}

test {
    useJUnitPlatform()
}

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

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    //rest docs
    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'

    ...

}

asciidoctor {
    inputs.dir snippetsDir
    dependsOn test // asciidoctor를 수행하기전 test를 수행하도록 선언
    configurations 'asciidoctorExt'
    baseDirFollowsSourceFile()
}

tasks.named('test') {
    useJUnitPlatform()
    outputs.dir snippetsDir
}

tasks.register('copyDocument', Copy) { // (12)
    dependsOn asciidoctor
    from file("${asciidoctor.outputDir}")
    into file("src/main/resources/static/docs")
}

build {
    dependsOn copyDocument
}

문서를 업데이트하려면

image

build 또는 test를 하면 된다.


테스트 메서드 작성

Controller Test에 andDo() 메서드 안에 작성하고 싶은 내용을 넣으면 된다.

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
@Slf4j
@AutoConfigureRestDocs
@WebMvcTest(CharactersController.class)
public class CharacterControllerTest {

    private static final String STRING = "String";
    private static final String NUMBER = "Number";
    private static final String ARRAY = "Array";

    @Autowired
    protected RestDocumentationResultHandler restDocs;
    @Autowired
    protected MockMvc mockMvc;
    @Autowired
    protected ObjectMapper objectMapper;

    @MockBean
    private CharactersService charactersService;

    @Test
    @WithMockUser(username = "user", roles = {"GUEST"})
    public void getCharacterListTest() throws Exception {
        //give
        List<Stars> stars = List.of(Stars.FIVE);
        List<Country> countries = List.of(Country.INAZUMA, Country.MONDSTADT);
        List<Element> elements = List.of(Element.ANEMO, Element.ELECTRO);

        CharacterListRequest request = new CharacterListRequest(stars, countries, elements, null);

        Characters testCharacter1 = createCharacters("test", Country.INAZUMA, Element.ANEMO);
        Characters testCharacter2 = createCharacters("test2", Country.MONDSTADT, Element.ELECTRO);

        Pageable pageable = PageRequest.of(0, 20);
        List<CharacterListResponse> characterResponse = createCharacterResponse(testCharacter1, testCharacter2);

        //when
        when(
                charactersService.findAndCreateCharacterList(any(CharacterListRequest.class), eq(pageable))
        ).thenReturn(new PageImpl<>(characterResponse, pageable, characterResponse.size()));

        //then
        this.mockMvc.perform(get("/api/characters")
                        .params(RequestConverter.convertRequestToMultiValueMap(request))
                )
                .andDo(print())
                .andDo(MockMvcResultHandlers.print())
                .andDo(document("{class-name}/{method-name}",  // 문서 이름 설정
                        preprocessRequest(
                                modifyHeaders()  // 헤더 내용 수정
                                        .remove("Content-Length")
                                        .remove("Host"),
                                prettyPrint()),  // 한 줄로 출력되는 json에 pretty 포멧 적용
                        preprocessResponse(
                                modifyHeaders()
                                        .remove("Content-Length")
                                        .remove("X-Content-Type-Options")
                                        .remove("X-XSS-Protection")
                                        .remove("Cache-Control")
                                        .remove("Pragma")
                                        .remove("Expires")
                                        .remove("X-Frame-Options"),
                                prettyPrint()),
                        queryParameters(
                                parameterWithName("stars")
                                        .description("별")
                                        .attributes(new Attribute("type", ARRAY)),
                                parameterWithName("countries")
                                        .description("지역")
                                        .attributes(new Attribute("type", ARRAY))
                        ),
                        responseFields(
                                fieldWithPath("wrapper[].characterId")
                                        .type(STRING)
                                        .description("캐릭터 id")
                                        .attributes(new Attribute("constraints", "pk"))
                                        .optional()
                        )
                ))
                .andExpectAll(
                        status().isOk(),
                        content().string(containsString("test2"))
                );
    }

    private Characters createCharacters(String name, Country country, Element element) {
        return Characters.builder()
                .characterName(name)
                .element(element)
                .country(country)
                .stars(Stars.FIVE)
                .build();
    }

    private List<CharacterListResponse> createCharacterResponse(Characters... characters) {
        return Arrays.stream(characters).map(CharacterListResponse::of).toList();
    }

}

이제 테스트를 실행하면 build/generated-snnipets 아래를 보면 adoc 형식의 스니펫이 생성된다.

하지만 이 코드의 경우 테스트 코드가 늘어날 수록 중복 코드가 너무 많아진다. 이를 방지하기 위해 먼저 추상화를 진행하겠다.


RestDocs 추상화

RestConfig

테스트 전용 설정 파일을 정의해 문서 이름과 공통적으로 설정할 헤더 설정, json pretty print을 미리 해둔다. 아래의 코드는 테스트 코드를 작성한 {클래스명/테스트 메소드명} 으로 디렉토리를 지정해준 것이다.

다음과 같은 공통 부분을 해결할 수 있다.

  • 요청과 응답 json에 pretty format을 적용하는 부분
  • 문서 이름을 처리해 주는 부분
  • header를 숨기는 부분
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
@TestConfiguration
public class RestDocsConfig {

    @Bean
    public RestDocumentationResultHandler write() {
        return MockMvcRestDocumentation.document(
                "{class-name}/{method-name}", // identifier
                preprocessRequest(
                        modifyHeaders()
                                .remove("Content-Length")
                                .remove("Host"),
                        prettyPrint()
                ),
                preprocessResponse(
                        modifyHeaders()
                                .remove("Content-Length")
                                .remove("X-Content-Type-Options")
                                .remove("X-XSS-Protection")
                                .remove("Cache-Control")
                                .remove("Pragma")
                                .remove("Expires")
                                .remove("X-Frame-Options"),
                        prettyPrint()
                )
        );
    }

}


RestDocsSupport

RestDocSupport 추상 클래스를 선언하고 API 문서 테스트 클래스가 이를 상속하게 하자.

@Disabled로 테스트 할 클래스에서 해당 클래스 제외해준다. @Import로 작성한 RestDocsConfiguration을 추가해준다. @ExtendWithRestDocumentationExtension을 설정해주어 context를 제공해 Spring REST Docs가 잘 작동할 수 있도록 한다.

이 클래스를 상속한 클래스가 MockMvcObjectMapper를 선언할 필요가 없도록 미리 설정해준다.

Attribute를 간단하게 추가할 수 있도록 메서드를 추가한다.

@BeforeEach에서 MockMvc의 커스터마이즈를 진행한다. RestDocsConfig에서 설정했던 RestDocumentationResultHandler를 적용하고, 테스트 시 요청과 응답을 출력할 수 있도록 print() 메서드도 적용한다.

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
@Disabled
@Import(RestDocsConfig.class)
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@ExtendWith(RestDocumentationExtension.class)
public abstract class AbstractRestDocsTests {

    @Autowired
    protected RestDocumentationResultHandler restDocs;
    @Autowired
    protected MockMvc mockMvc;
    @Autowired
    protected ObjectMapper objectMapper;

    @BeforeEach
    void setUp(
            final WebApplicationContext context,
            final RestDocumentationContextProvider restDocumentation
    ) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(documentationConfiguration(restDocumentation))
                .alwaysDo(MockMvcResultHandlers.print())
                .alwaysDo(restDocs)
                .addFilters(new CharacterEncodingFilter("UTF-8", true))
                .build();
    }
}

참고: Spring REST Docs 사용법


테스트 코드 리팩토링

이렇게 해도 나는 테스트 코드가 길게 느껴졌다. 더 추상화시키기 위해 Field라는 클래스를 만들어 간단하게 작성할 수 있도록 하였다.

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
@Getter
public class Field {
    private String path;
    private Object type;
    private String description;
    private String constraints;
    private boolean optional;

    public Field(String path, Object type, String description) {
        this.path = path;
        this.type = type;
        this.description = description;
        this.constraints = null;
        this.optional = false;
    }

    public Field(String path, Object type, String description, String constraints, boolean optional) {
        this.path = path;
        this.type = type;
        this.description = description;
        this.constraints = constraints;
        this.optional = optional;
    }

    public static List<FieldDescriptor> toFieldDescriptors(List<Field> fields) {
        return fields.stream()
                .map(field -> {
                    FieldDescriptor descriptor = fieldWithPath(field.getPath())
                            .type(field.getType())
                            .description(field.getDescription());
                    if (field.isOptional()) {
                        descriptor.optional();
                    }
                    if (field.getConstraints() != null && !field.getConstraints().isEmpty()) {
                        descriptor.attributes(constraints(field.getConstraints()));
                    }
                    return descriptor;
                })
                .collect(Collectors.toList());
    }

    public static List<ParameterDescriptor> toQueryDescriptors(List<Field> fields) {
        return fields.stream()
                .map(field -> {
                    ParameterDescriptor parameterDescriptor = parameterWithName(field.getPath())
                            .description(field.getDescription())
                            .attributes(
                                    Attributes.key("type").value(field.getType())
                            );
                    if (field.isOptional()) {
                        parameterDescriptor.optional();
                    }
                    if (field.getConstraints() != null) {
                        parameterDescriptor.attributes(constraints(field.getConstraints()));
                    }
                    return parameterDescriptor;
                })
                .collect(Collectors.toList());
    }
}


아래는 Field List를 스니펫으로 바꿔주는 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class RestDocsUtil {

    public static QueryParametersSnippet generateRequestParams(List<Field> fields) {
        return queryParameters(Field.toQueryDescriptors(fields));
    }

    public static RequestFieldsSnippet generateRequestFields(List<Field> fields) {
        return requestFields(Field.toFieldDescriptors(fields));
    }

    public static ResponseFieldsSnippet generateResponseFields(List<Field> fields) {
        return responseFields(Field.toFieldDescriptors(fields));
    }

    public static Attribute constraints(String constraint) {
        return key("constraints").value(constraint);
    }
}

이제 테스트 코드에 저 코드들을 적용해보자.

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
@Slf4j
@AutoConfigureRestDocs
@WebMvcTest(CharactersController.class)
public class CharacterControllerTest extends AbstractRestDocsTests {

    private static final String STRING = "String";
    private static final String NUMBER = "Number";
    private static final String ARRAY = "Array";

    @MockBean
    private CharactersService charactersService;

    @Test
    @WithMockUser(username = "user", roles = {"GUEST"})
    public void getCharacterListTest() throws Exception {
        //give
        List<Stars> stars = List.of(Stars.FIVE);
        List<Country> countries = List.of(Country.INAZUMA, Country.MONDSTADT);
        List<Element> elements = List.of(Element.ANEMO, Element.ELECTRO);

        CharacterListRequest request = new CharacterListRequest(stars, countries, elements, null);

        Characters testCharacter1 = createCharacters("test", Country.INAZUMA, Element.ANEMO);
        Characters testCharacter2 = createCharacters("test2", Country.MONDSTADT, Element.ELECTRO);

        Pageable pageable = PageRequest.of(0, 20);
        List<CharacterListResponse> characterResponse = createCharacterResponse(testCharacter1, testCharacter2);

        //when
        when(
                charactersService.findAndCreateCharacterList(any(CharacterListRequest.class), eq(pageable))
        ).thenReturn(new PageImpl<>(characterResponse, pageable, characterResponse.size()));

        //then
        this.mockMvc.perform(get("/api/characters")
                        .params(RequestConverter.convertRequestToMultiValueMap(request))
                )
                .andDo(print())
                .andDo(restDocs.document(
                        RestDocsUtil.generateRequestParams(createRequestParams()),
                        RestDocsUtil.generateResponseFields(createResponseField())
                ))
                .andExpectAll(
                        status().isOk(),
                        content().string(containsString("test2"))
                );
    }

    private Characters createCharacters(String name, Country country, Element element) {
        return Characters.builder()
                .characterName(name)
                .element(element)
                .country(country)
                .stars(Stars.FIVE)
                .build();
    }

    private List<CharacterListResponse> createCharacterResponse(Characters... characters) {
        return Arrays.stream(characters).map(CharacterListResponse::of).toList();
    }

    private List<Field> createRequestParams() {
        return Arrays.asList(
                new Field("stars", ARRAY, "5성/4성", "Enum stars", true),
                new Field("countries", ARRAY, "지역", "Enum Country", true),
                new Field("elements", ARRAY, "원소", "Enum Element", true),
                new Field("weaponTypes", ARRAY, "무기 종류", "Enum WeaponType", true)
        );
    }

    private List<Field> createResponseField() {
        return Arrays.asList(
                new Field("wrapper[].characterId", STRING, "캐릭터 id", "pk", false),
                new Field("wrapper[].characterName", STRING, "캐릭터 이름"),
                new Field("wrapper[].characterImage", STRING, "캐릭터 이미지 경로"),
                new Field("wrapper[].stars", STRING, "캐릭터의 별", "Enum Stars", false),

                new Field("page.currentPage", NUMBER, "현재 페이지"),
                new Field("page.totalPages", NUMBER, "총 페이지"),
                new Field("page.totalElements", NUMBER, "총 아이템 개수"),

                new Field("message", STRING, "메세지")
        );
    }
}

더 줄일 수 있을 것이라 생각하지만 우선은 여기서 멈추기로 하였다.


문서화

커스텀 스니펫 적용하기

커스텀 스니펫은 resources/org/springframework/restdocs/templates 아래에 작성하면 된다. 기존 스니펫에 오버라이딩해서 커스터마이징 한다고 생각하면 된다.

커스텀 스니펫

내가 작성한 스니펫들은 이렇다. 스니펫 코드가 블로그에서 제대로 뜨지 않는 관계로 사진도 추가하였다.

query-parameters.snippet

1
2
3
4
5
6
7
8
9
10
11
|===
|Parameter|Type|Description|Optional


|`++`
|
|
|OX


|===

query-parameters

request-fields.snippet

1
2
3
4
5
6
7
8
9
10
|===
|필드명|타입|필수여부|제약조건|설명

|`++`
|`++`
|OX
|
|

|===

request-fields

response-fields.snippet

1
2
3
4
5
6
7
8
9
|===
|필드명|타입|제약조건|설명

|`++`
|`++`
|
|

|===

response-fields


문서 작성

src/docs/asciidoc에 문서 작성을 해준다. 예시는 다음과 같다.

index.adoc

1
2
3
4
5
6
7
8
= Spring REST Docs Test
:doctype: book
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:seclinks:

include::characters.adoc[]

characters.adoc

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
= Coonect API Document
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2

== Characters 관련 API

=== 캐릭터 리스트 조회

==== 요청

include::{snippets}/character-controller-test/get-character-list-test/http-request.adoc[]

==== 요청 필드

include::{snippets}/character-controller-test/get-character-list-test/query-parameters.adoc[]

==== 응답

include::{snippets}/character-controller-test/get-character-list-test/http-response.adoc[]

==== 응답 필드

include::{snippets}/character-controller-test/get-character-list-test/response-fields.adoc[]

이제 코드를 실행하면 아래와 같이 문서가 생성된다.

image

실행화면

최종 코드는 이곳에서 확인할 수 있다.

This post is licensed under CC BY 4.0 by the author.