끄적끄적

Spring Rest Docs 사용하기 본문

개발/java & spring

Spring Rest Docs 사용하기

코리이 2022. 11. 1. 18:30

서론

백앤드를 하다보면 가장 중요한 작업 중 하나가 API 문서 작성이다. 필자의 경우 다른 플랫폼에서는

대부분 Swagger 를 이용해 왔는데 Spring 에서는 테스트코드를 바탕으로 api docs 를 만들어 주는 프레임워크이다. 특히나 테스트 코드를 강제할 수 있다는 부분에서 매력적으로 다가왔다. 그래서 이번에 간단한 사용법에 대해서 적어볼까 한다.

시작하기

우선 이전 포스팅에서 구성한 멀티모듈 프로젝트를 바탕으로 만들어볼까 한다. 우리의 멀티모듈 프로젝트에서 restdocs 를 사용할 부분은 api 모듈 부분이다. 이 모듈의 build.gradle 에 rest docs 에 필요한 설정들을 정의해준다. 또한 포스팅에서는 WebTesetClient 를 사용해서 작성할 예정이므로 이에대한 부분또한 추가해준다.

처음에 공식문서에서 하라는 대로 했는데 html 파일이 만들어지는 경로가 html5 가 아니라 docs/asciidoc/ 으로 만들어져서 이를 수정하는 작업이 필요했다.

그 외에는 크게 다른 부분 없이 차근차근 따라가면서 설정하면 된다.

plugins {
    id "org.asciidoctor.jvm.convert" version "3.3.2"
}

configurations {
    asciidoctorExt
}

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

test {
    outputs.dir snippetsDir
}

// asciidoctor 작업 정의
asciidoctor {
    dependsOn test
    configurations 'asciidoctorExt'
    inputs.dir snippetsDir

    // 특정 .adoc에 다른 adoc 파일을 가져와서(include) 사용할 때 경로를 baseDir로 맞춰주는 설정
    baseDirFollowsSourceFile()
}

// static/docs 폴더 비우기
asciidoctor.doFirst {
    delete file('src/main/resources/static/docs')
}


bootJar {
    enabled = true

    // jar 파일에 html 파일 설정하기
    dependsOn asciidoctor
    from ("${asciidoctor.outputDir}") {
        into 'static/docs'
    }
}

jar {
    enabled = true
}

// 실제 코드에 index.html 파일 설정
task copyDocument(type: Copy) {
    dependsOn asciidoctor
    from file("build/docs/asciidoc")
    into file("src/main/resources/static/docs")
}

// build 의 의존작업 명시
build {
    dependsOn copyDocument
}

dependencies {
    implementation project(':identity')
    implementation project(':order')

    // webclient 를 써야하므로 webflux 종속성도 추가한다.
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // rest webtestclient & asciidoc
    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:2.0.6.RELEASE'
    testImplementation 'org.springframework.restdocs:spring-restdocs-webtestclient'
}

예시

우선 AppController 를 아래처럼 path 도 받고 query, body 를 받도록 조금 수정해주자.

물론 실제 운영코드는 이렇게 코딩하지 않겠지만 실습을 위해서 여러가지를 한번 받아보도록 하자.

또한 CreateOrder 클래스의 경우 단순히 status 만을 가지고 있는 DTO 클래스이니 따로 명시하지는 않았다.

@RestController
public class AppController {

    @GetMapping("/api/identities/{identityId}")
    public Identity getIdentity(@PathVariable String identityId,
                                @RequestParam String provider) {
        return new Identity(identityId, provider);
    }

    @PostMapping("/api/orders/{orderId}")
    public Order createOrder(
            @PathVariable String orderId,
            @RequestBody CreateOrder createOrder) {
        return new Order(orderId, createOrder.status);
    }

    @GetMapping("/health")
    public String health() {
        return "healthy";
    }
}

그 후에는 이제 테스트 코드를 작성해보자. 우선 GetIdentityTest 를 가지고 테스트 해 볼 예정이다.

restdocs 에 대한 문법(pathParameters, queryParameters 등)은 공식문서에 자세히 나와 있으므로 그것을 참고하도록 하자.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
public class GetIdentityTest {

    @LocalServerPort
    private int port;

    @Autowired
    private WebTestClient webTestClient;

    @BeforeEach
    public void setUp(RestDocumentationContextProvider restDocumentation) {
        this.webTestClient = WebTestClient
                .bindToServer().baseUrl("http://localhost:" + port)
                .filter(documentationConfiguration(restDocumentation))
                .build();
    }

    @DisplayName("identity 를 반환한다.")
    @Test
    public void sut_returns_Identity() {
        String nickname = "pawmi";
        String identityId = "23";
        String url = "/api/identities/{identityId}?nickname=" + nickname;
        EntityExchangeResult<Identity> result = this.webTestClient.get().uri(url, identityId).accept(MediaType.APPLICATION_JSON)
                .exchange().expectStatus().isOk().expectBody(Identity.class)
                .consumeWith(document("identities",
                        preprocessResponse(prettyPrint()),
                        pathParameters(
                                parameterWithName("identityId")
                                        .description("인증 id")
                        ),
                        queryParameters(
                                parameterWithName("age")
                                        .optional()
                                        .description("나이"),
                                parameterWithName("nickname")
                                        .description("닉네임")
                        ),
                        responseFields(
                                fieldWithPath("id").type(JsonFieldType.STRING).description("인증 Id"),
                                fieldWithPath("name").type(JsonFieldType.STRING).description("닉네임")
                        )
                )).returnResult();

        Identity body = result.getResponseBody();

        assertThat(body.getId()).isEqualTo(identityId);
        assertThat(body.getName()).isEqualTo(nickname);
    }
}

작성을 한 뒤 테스트를 통과하면 빌드 파일에 document 에서 지정한 identities 의 이름으로 빌드 snippet 파일들이 생성되어 있는 것을 확인할 수 있다.

이를 가지고 기본 html 파일을 생성하도록 adoc 파일을 만들어줘야 한다. src/docs/asciidoc 파일에 생성하고자 하는 파일을 asciidoc 파일로 만들어주자. 현재 포스팅에서는 identity.adoc 을 만들어서 작업했다. 위의 사진에서 나오는 파일을 가지고 만들면 되며, 이 asciidoc 관련 플러그인이 intellij 에 존재하므로 사용하면 쉽게 적용할 수 있다.

[[identity]]
== identity API

=== Request

CURL:

include::{snippets}/identities/curl-request.adoc[]

Path Parameters:

include::{snippets}/identities/path-parameters.adoc[]

Query Parameters:

include::{snippets}/identities/query-parameters.adoc[]

Request HTTP Example:

include::{snippets}/identities/http-request.adoc[]

=== Response

Response Fields:

include::{snippets}/identities/response-fields.adoc[]

Response HTTP Example:

include::{snippets}/identities/http-response.adoc[]

 

그 후에 프로젝트를 빌드해보자. 그러면 아래 사진처럼 docs 아래에 html 파일이 생기고 이것이 resources 디렉토리에 복사되어 있는 것 또한 확인할 수 있다. 또한 앱을 실행시켜서 /docs/identity.html 로 접속하면 docs 가 잘 나오는 것을 확인할 수 있다.

 

여기까지 완료했다면 기본적인 restdocs 사용법을 끝났다. 

비슷한 방식으로 CreateOrder 또한 만들어주자.

아래에 테스트 코드만 간단하게 적고 넘어가도록 할 것이다. asciidoc 파일은 알아서 만들어보자. (api 마다 다른 docs 를 만들어야 하므로 여기서는 create-order 로 설정했다)

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
public class CreateOrderTest {

    @LocalServerPort
    private int port;

    @Autowired
    private WebTestClient webTestClient;

    @BeforeEach
    public void setUp(RestDocumentationContextProvider restDocumentation) {
        this.webTestClient = WebTestClient
                .bindToServer().baseUrl("http://localhost:" + port)
                .filter(documentationConfiguration(restDocumentation))
                .build();
    }

    @DisplayName("생성한 order 를 반환한다.")
    @Test
    public void sut_returns_Order() {
        CreateOrder createOrder = new CreateOrder("pending");
        String orderId = "2";
        String url = "/api/orders/{orderId}";
        EntityExchangeResult<Order> result = this.webTestClient
                .post()
                .uri(url, orderId)
                .bodyValue(createOrder)
                .accept(MediaType.APPLICATION_JSON)
                .exchange().expectStatus().isOk().expectBody(Order.class)
                .consumeWith(document("create-order",
                        preprocessResponse(prettyPrint()),
                        pathParameters(
                                parameterWithName("orderId")
                                        .description("주문 id")
                        ),
                        requestFields(
                                fieldWithPath("status").type(JsonFieldType.STRING).description("주문 상태")
                        ),
                        responseFields(
                                fieldWithPath("id").type(JsonFieldType.STRING).description("주문 Id"),
                                fieldWithPath("status").type(JsonFieldType.STRING).description("주문 상태")
                        )
                )).returnResult();

        Order body = result.getResponseBody();

        assertThat(body.getId()).isEqualTo(orderId);
        assertThat(body.getStatus()).isEqualTo("pending");
    }
}

문서 분리하기

api 마다 다른 url 로 접근해야 한다면 오히려 보기 불편할 수 있다. 그래서 모든 api 를 index.html 하나의 url 로 설정할 수 있도록 옮겨보자. 이 부분은 include 를 활용하면 간단하게 적용 가능하다.

다시 src/docs/asciidoc 디렉토리에 index.adoc 을 생성해서 아래처럼 만들어보자. 

= API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 3
:sectlinks:

[[common]]
== 공통 사항

API에 관계없이 아래 사항을 지켜주셔야 합니다.

=== Header

|===
| name | 설명

| `Authorization`
| API를 사용하기 위한 인증 키
|===

include::identity.adoc[]
include::order.adoc[]

그러면 이제 아래 사진과 같이 identity.adoc, order.adoc 에서 작성한 것이 index.html 에 모일 것이다.

문서 Customize

가장 유명한 커스텀으로는 필수값 표기가 있을 것 같다. optional 이 적용된 값을 false 로 지정하고 나머지는 true 로 지정할 수 있다.

그 이외에도 여러 제약조건을 걸 수 있다.

이를 optional, constraint 로 지정해서 사용할 수 있다. getIdentity 의 경우 age 를 받거나 안받을 수 있고 nickname 은 무조건 받야아만 하도록 query parameter 를 한번 고쳐보자. 

또한 constraint 를 커스텀으로 제공해서 특정 조건을 명시하도록 설정할 수 있다.

우선 resources/org/springframework/restdocs/templates/asciidoctor  경로에 생성되는 기본 snippet 을 덮어줄 수 있다.

실습할 query parameter 의 경우 query-parameters.snippet 파일을 만들어서 아래처럼 코드를 지정할 수 있다.

|===
|파라미터|필수값|제약조건|설명

{{#parameters}}
|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{^optional}}true{{/optional}}{{/tableCellContent}}
|{{#tableCellContent}}{{ #constraints }}{{.}}{{ /constraints }}{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}

{{/parameters}}
|===

 

 

 

그 후에 identity 테스트 코드에서 queryParameter 부분만 attribute 와 optional 을 사용하도록 고쳐보자

queryParameters(
        parameterWithName("age")
                .optional()
                .attributes(new Attributes.Attribute("constraints", "20~29 등 범위 지정"))
                .description("나이"),
        parameterWithName("nickname")
                .attributes(new Attributes.Attribute("constraints", "닉네임이 포함된 최근 것을 가져옴"))
                .description("닉네임")
),

그 후에 다시 빌드해보면 아래처럼 커스텀한 문서 형식을 발견할 수 있을 것이다.

다른 문서 형식도 바꾸고 싶다면 쉽게 바꿀 수 있을 것이다.

결론

일단 시작하기에는 이정도로도 충분할 것 같다는 생각이 든다. 하지만 개인적으로는 docs 를 만드는 테스트와 실제 api 테스트를 분리해서 docs 를 만드는 테스트는 mockmvc 테스트로 따로 지정하는게 더 빠르고 낫지 않을까 하는 생각이 들었다.

또한 공통적인 부분을 분리한다면 좀 더 간결하게 문서를 생성하는 코드를 작성할 수 있지 않을까 싶다. 실제로 docs 를 사용해보니 다른 플랫폼에도 이런 문서 생성기가 있다면 좀 더 편하지 않을까 하는 생각이 들기도 했다.