조금 평범한 개발 이야기

Spring Rest Docs 로 API 명세서 자동 생성하기 본문

개발/Spring Rest Docs

Spring Rest Docs 로 API 명세서 자동 생성하기

jogeum 2019. 8. 3. 04:12

사용기술

  • java 11
  • Spring Boot 2.1.6.RELEASE
  • maven

개요

이제 다양한 프로젝트에서 모듈간 통신을 하는 방법으로 Rest API 를 사용하는 것은 당연한 일이 되었고 이렇게 만들어 놓은 Rest API 명세를 작성하고 관리 하는 것 역시 중요한 일이 되었습니다.

하지만 항상 요구 사항은 변경되기 마련이고 이에 따라 언제든 Rest API 가 변경될 가능성이 높아졌지만 작성해둔 Rest API 의 명세서가 최신 내용을 반영하고 있다고 확신하기는 어렵습니다. 손으로 문서를 변경하는 것은 한계가 있기 때문입니다.

Spring 에서 Rest API 의 명세서를 작성하는 방식은 크게 SwaggerRestDocs 로 나뉘어 지는데 이중 TDD 개발 방식과 접목하여 소스 코드에 영향을 주지 않는 선에서 간편하게 명세서를 작성할 수 있는 RestDocs 를 사용하여 API 명세서를 작성하는 방법에 대해서 설명하겠습니다.

RestDocs 은 미리 작성해둔 명세서의 템플릿을 기록해 둔 테스트케이스를 통과해야만 명세서를 작성해 주기 때문에 항상 균일한 코드 품질 관리와 최신의 API 명세를 관리할 수 있습니다.

Swagger Spring Rest Docs
어노테이션 기반이라 상대적으로 쉽게 적용할 수 있다. 명세서를 만드는데 굉장히 까다로운 과정을 거쳐야 한다.
소스 코드에 어노테이션으로 명세에 대한 내용을 기술 하기 때문에 최신 명세에 대한 보장을 하기가 어렵다. 한번만 만들어 두면 나머지는 크게 신경쓰지 않아도 된다.

앞서 잠시 이야기 한 것과 같이 RestDocs 을 이용해 API 명세서를 생성하는 일은 생각보다 지난한 과정을 거쳐야 합니다. 하지만 한번 고생하면 꾸준히 좋은 퀄리티의 문서를 얻을 수 있으니 차근차근 어떤식으로 작성을 해야 하는지에 대해서 이야기 드리겠습니다.

설명순서

  1. 테스트 환경 구축
  2. 테스트케이스 작성
  3. RestDocs 작성
  4. 스니펫 (Snippet) 문서에 대해서 설명
  5. 테스트케이스에 API 명세서 작성
  6. RestDocs maven build 설정
  7. 소스코드
  8. 참고자료

테스트 환경 구축

RestDocs 을 적용하기 위해 가장 먼저 해야 되는 작업은 테스트 환경을 구축하는 일입니다. RestDocs 은 단독으로 동작하지 않으며 Junit 기반으로 작성된 테스트케이스를 먼저 실행하고 테스트가 성공적으로 완료 되었을때만 API 에 대한 스니펫 명세 문서를 생성해 줍니다.

따라서 테스트케이스를 쉽게 작성하기 위해 필요한 코드를 담아 ApiDocTest.java 를 정의해놓습니다. (프로젝트 상에 src/test/java 폴더에 위치 시킵니다.)

ApiDocTest.javaMockMvc, RestDocumentationResultHandler 와 같이 테스트케이스와 RestDocs에 필요한 object 를 미리 정의해둔 코드 입니다. 명세서를 위한 테스트케이스를 작성 할때 이 ApiDocTest.java 를 상속 받아 테스트케이스를 작성 하면 됩니다.

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = RestdocsApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("local")
abstract public class ApiDocTest {

    @Rule
    public JUnitRestDocumentation restDocumentation = new JUnitRestDocumentation();

    @Autowired
    protected WebApplicationContext wac;
    protected MockMvc mockMvc;
    protected RestDocumentationResultHandler document;

    @Before
    public void setup() {
        this.document = document(
                "{class-name}/{method-name}",
                preprocessResponse(prettyPrint())
        );
        this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac)
                .apply(documentationConfiguration(this.restDocumentation))
                .alwaysDo(document)
                .build();
    }

    @Test
    public void testReady() {
        assertNotNull("WebApplicationContext 로딩 실패.", wac);
        assertNotNull("MockMvc 생성 실패.", mockMvc);
    }
}

ApiDocTest.java 는 test 와 관련된 코드이기 때문에 intellij 기준으로 test source folder 에 위치하고 있어야 합니다. 즉 테스트케이스와 같은 위치에 있어야 됩니다.


테스트케이스 작성

프로젝트에 필요한 테스트케이스는 과연 어디까지 정의해야 되는가? 에 대한 부분은 사실 결정 하기가 어려운 부분 입니다. 테스트케이스의 coverage 가 100%가 되면 참 좋겠지만 현실적으로 그렇게 작성 하기가 어렵기 때문입니다. 그렇기 때문에 최소한 외부와 통신을 하는 Rest API 에 대해서는 반드시 테스트케이스를 작성하고 프로젝트가 배포 될때마다 테스트를 진행해 프로젝트의 외부 인터페이스가 깨지는 상황을 사전에 방지해야 합니다.

또한 Rest API 를 위해 작성된 테스트케이스에 API 명세에 대한 내용을 추가해 테스트케이스가 성공적으로 동작이 될때마다 매번 최신 버전으로 API 명세에 대한 내용이 갱신 되도록 하는 방법이 RestDocs 을 적용하는 목적이라고 할 수 있습니다. 잘 작성된 테스트케이스를 약간만 손봐서 테스트케이스를 활용한 문서 작성을 한다는 이야기 입니다.

사용자 정보를 제공하는 Rest Api Controller

기본적인 테스트케이스를 작성하기에 앞서 먼저 아래와 같은 단순한 형태의 정보를 제공해 주는 Rest API 가 있다고 가정해 보겠습니다. 자세한 소스의 구현에 대한 내용은 주제에 벗어 나기 때문에 설명하지 않겠습니다.

@RestController
@RequestMapping("/v1/users")
public class UserController {

    private UserService userService;

    @Autowired
    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public ResponseEntity<List> getList() {
        return ResponseEntity.ok(userService.getList());
    }

    @GetMapping("/{userId:.+}")
    public ResponseEntity<UserDTO> getOne(@PathVariable String userId) {
        return ResponseEntity.ok(userService.getOne(userId).get());
    }
}

 

사용자 정보 테스트케이스

사용자 정보 Controller 에 대한 테스트케이스를 작성하기 위해서 intellij 기준으로 generate > Test 를 선택해 테스트케이스를 생성 합니다.

UserController 에 대한 테스트케이스는 아래와 같은 형태를 띄고 있는데 Spring MVC Test 를 통해 실제 Rest API 에 대한 요청을 보내고 응답 결과값과 응답 상태가 의도 하는데로 나오는지를 테스트 해 볼 수 있습니다.

만약 테스트케이스를 처음 적용한다면 given-when-then 스타일을 사용해 테스트 코드를 작성하는 방식을 고려해 볼 수도 있습니다.

given-when-then 스타일이란 테스트에 필요한 사전 작업을 진행하고, 실제 테스트 실행한 다음, 마지막으로 테스트 결과를 확인 하는 단계를 각각 나누어 처리하는 방법입니다. 이렇게 각각의 단계를 나눔으로써 각 단계가 정확하게 실행이 되었는지를 쉽게 확인 할 수 있으며 전체적인 테스트케이스의 가독성을 높일 수 있어 전체적인 코드 품질을 높일 수 있게 됩니다.

  • Given : 테스트 전의 상태를 만듭니다.
  • When : 테스트를 실행합니다.
  • Then : 테스트 결과가 실패없이 잘 실행 되었는지 확인합니다.
public class UserControllerTest extends ApiDocTest {
    private String TEST_URL = "/v1/users";

    @Test
    public void getList() throws Exception {
        //given

        //when
        ResultActions result = mockMvc.perform(get(TEST_URL));

        //then
        result.andExpect(status().isOk())
                .andDo(print())
                .andReturn();
    }

    @Test
    public void getUser() throws Exception {
        //given

        //when
        ResultActions result = mockMvc.perform(get(TEST_URL + "/{userId}", "jogeum"));

        //then
        result.andExpect(status().isOk())
                .andDo(print())
                .andReturn();
    }
}

 


RestDocs 작성

테스트케이스가 기대 했던데로 실패 없이 정상적으로 동작이 된다면 이제 테스트케이스에 API 명세에 필요한 내용을 추가로 작성할때가 되었습니다. Rest API 에 대한 명세를 작성할때 Request 와 Response 에 대한 header 정보, 그리고 request parameter, path parameter, multipart request 마지막으로 결과값을 포함하는 response 에 대한 명세를 각각 작성 할 수 있습니다.

작성이 가능한 명세에 대한 코드는 아래와 같습니다.

request header

requestHeaders(headerWithName("Authorization").description("Basic auth credentials"))

response header

responseHeaders(headerWithName("X-RateLimit-Limit").description("The total number of requests permitted per period"))

request parameter

requestParameters(parameterWithName("page").description("The page to retrieve"))

path parameter

pathParameters(parameterWithName("latitude").description("The location's latitude"))

multipart request

requestPartBody("metadata")
requestPartFields("metadata", fieldWithPath("version").description("The version of the image"))

response

responseFields(fieldWithPath("contact.email").description("The user's email address"))

response 에 대한 명세를 작성할때 주의할 점은 실제 반환되는 항목과 명세에 작성된 내용이 불일치 하면 에러가 발생이 된다는 점 입니다. 그러니 불필요해 보이는 항목 이라도 명세에 꼭 포함을 시켜 줘야 합니다.

만약 보다 정확한 API 명세를 작성하고 싶다면 JsonFieldType 을 명시해 어떤 타입이 반환 되는지를 지정 할 수 도 있습니다.

response

responseFields(fieldWithPath("contact.email").type(**JsonFieldType.STRING**).description("The user's email address"))

그럼 방금 작성한 사용자 테스트케이스에 RestDocs API 명세에 대한 내용을 추가로 작성해 보겠습니다.

public class UserControllerTest extends ApiDocTest {
    private String TEST_URL = "/v1/users";

    @Test
    public void getList() throws Exception {
        //given

        //when
        ResultActions result = mockMvc.perform(get(TEST_URL));

        //then
        result.andExpect(status().isOk())
                .andDo(print())
                .andDo(document.document(
                        responseFields(
                                fieldWithPath("[]").description("사용자 리스트"),
                                fieldWithPath("[].id").description("사용자 id"),
                                fieldWithPath("[].name").description("사용자 이름"),
                                fieldWithPath("[].email").description("사용자 이메일")
                        )
                ))
                .andReturn();
    }

    @Test
    public void getUser() throws Exception {
        //given

        //when
        ResultActions result = mockMvc.perform(get(TEST_URL + "/{userId}", "jogeum"));

        //then
        result.andExpect(status().isOk())
                .andDo(print())
                .andDo(document.document(
                        pathParameters(
                                parameterWithName("userId").description("조회할 대상자 id")
                        ),
                        responseFields(
                                fieldWithPath("id").description("사용자 id"),
                                fieldWithPath("name").description("사용자 이름"),
                                fieldWithPath("email").description("사용자 이메일")
                        )
                ))
                .andReturn();
    }
}

 


스니펫 (Snippet) 문서

RestDocs 에 대한 명세내역을 코드에 작성한 후 테스트케이스가 정상적으로 실행이 되었다면 작성한 RestDocs 내용 기준으로 스니펫 문서들이 생성이 되는데 maven 일 경우 프로젝트의 target/generated-snippets 폴더에 생성이 됩니다.

문서는 다양한 형태로 생성이 되는데 (요청에 대한 문서, 요청 값에 대한 문서, 요청의 반환 값에 대한 문서) 등을 제공해 줍니다.

만약 테스트케이스에 명세에 대한 내용이 기술되어 있지 않으면 스니펫 문서가 만들어지지 않기도 하는데 예를 들어 request-parameter, path-parametersrequest-fields 와 같은 내용은 API 성격에 따라 API SPEC 에 포함되어 있지 않기 때문입니다.

snippet 종류

  • curl-request.adoc : 호출에 대한 curl 명령을 포함 하는 문서
  • httpie-request.adoc : 호출에 대한 http 명령을 포함 하는 문서
  • http-request.adoc : http 요청 정보 문서
  • http-response.adoc : http 응답 정보 문서
  • request-body.adoc : 전송된 http 요청 본문 문서
  • response-body.adoc : 반환된 http 응답 본문 문서
  • request-parameters.adoc : 호출에 parameter 에 대한 문서
  • path-parameters.adoc : http 요청시 url 에 포함되는 path parameter 에 대한 문서
  • request-fields.adoc : http 요청 object 에 대한 문서
  • response-fields.adoc : http 응답 object 에 대한 문서

이중에서 RestDocs 문서를 만들기 위해 사용될때 가장 우선순위가 높은 문서는 (curl-request.adoc, request-parameters.adoc, path-parameters.adoc, request-fields.adoc, response-fields.adoc) 가 되는데 이 외의 스니펫 문서는 필요에 따라 선택적으로 포함 시켜 줍니다.

자주 사용되는 snippet 문서

  • curl-request.adoc
  • request-parameters.adoc,
  • path-parameters.adoc
  • request-fields.adoc
  • response-fields.adoc

API 명세서 작성

스니펫 문서가 정상적으로 생성이 되었다면 마지막 단계로 생성된 스니펫 문서들을 모아 정리한 별도의 API 명세서를 만들어야 합니다. API 명세서는 asciidoc 문법으로 작성이 되는데 asciidocmarkdown 과 유사한 문서 작성 문법 이지만 markdown 보다 상대적으로 사용하기 쉽다는 장점이 있습니다.

API 명세서에는 API SPEC 에 대한 추가 설명과 명세서 작성에 필요한 스니팻을 추가해 주는데 이렇게 만들어진 API 명세서는 maven 빌드시 최종적으로 만들어지는 산출물 (html) 의 기준 양식이 됩니다.

API 명세서에 스니펫을 추가해 주기 위해 먼저 앞서 생성한 스니펫 문서의 경로를 지정 합니다.

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

그런 다음 생성된 스니펫 중 사용하기 원하는 스니펫을 추가해 줍니다. 문서에 대한 설명 내용은 앞서 소개 한 것과 같이 asciidoc 양식으로 작성이 됩니다.

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

이렇게 생성한 API 명세서는 아래와 같은 유사한 형태를 가지게 됩니다. 이때 만들어지는 명세서는 src/main/asciidoc/api-guide.adoc 에 위치 시킵니다.

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

= API document
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:


== 사용자 API

=== 사용자 전체 조회
==== curl 호출예시
include::{snippets}/user-controller-test/get-list/curl-request.adoc[]
==== 응답명세
include::{snippets}/user-controller-test/get-list/response-fields.adoc[]


=== 특정 사용자 조회
==== curl 호출예시
include::{snippets}/user-controller-test/get-user/curl-request.adoc[]
==== Path 인자
include::{snippets}/user-controller-test/get-user/path-parameters.adoc[]
==== 응답명세
include::{snippets}/user-controller-test/get-user/response-fields.adoc[]

 


maven build 설정

API 명세서는 앞서 설명한 것과 같이 asciidoc 문법을 사용해 작성하며 필요한 스니펫 문서를 추가해 만들어지게 되는데 최종 산출물을 만들기 위해 마지막 단계인 maven 빌드 단계를 추가해 API 명세서가 html 로 변환되는 과정을 거칩니다. 이렇게 만들어진 html 버전의 API 명세서를 웹 영역에서도 확인 할 수 있게 Spring Bootstatic 영역으로 복사하는 maven 빌드 속성도 함께 추가해 줍니다.

maven 빌드에 추가 하는 내용

  1. API 명세서를 html 로 변환
  2. html 로 변환된 API 명세서를 static 영역으로 복사
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    ...
    <build>
        <plugins>
            ...
            <!-- 1. API 명세서를 html로 변환 -->
            <plugin>
                <groupId>org.asciidoctor</groupId>
                <artifactId>asciidoctor-maven-plugin</artifactId>
                <version>1.5.3</version>
                <executions>
                    <execution>
                        <id>generate-docs</id>
                        <phase>prepare-package</phase>
                        <goals>
                            <goal>process-asciidoc</goal>
                        </goals>
                        <configuration>
                            <backend>html</backend>
                            <doctype>book</doctype>
                        </configuration>
                    </execution>
                </executions>
                <dependencies>
                    <dependency>
                        <groupId>org.springframework.restdocs</groupId>
                        <artifactId>spring-restdocs-asciidoctor</artifactId>
                        <version>2.0.2.RELEASE</version>
                    </dependency>
                </dependencies>
            </plugin>

            <!-- 2. html 로 변환된 API 명세서를 static 영역으로 복사 -->
            <plugin>
                <artifactId>maven-resources-plugin</artifactId>
                <version>3.0.1</version>
                <executions>
                    <execution>
                        <id>copy-resources</id>
                        <phase>prepare-package</phase>
                        <goals>
                            <goal>copy-resources</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>
                                ${project.build.outputDirectory}/static/docs
                            </outputDirectory>
                            <resources>
                                <resource>
                                    <directory>
                                        ${project.build.directory}/generated-docs
                                    </directory>
                                </resource>
                            </resources>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
            ...
        </plugins>
    </build>
    ...
</project>

API 명세서 산출물

이렇게 maven 빌드를 실행하면 html 버전의 API 명세서가 target/classes/static/docs/api-guide.html 위치에 생성이 되며 배포시 함께 배포가 됩니다.

mvn package    

Spring Boot 프로젝트를 실행하면 위에서 작성한 API 명세서의 내용을 아래 주소에서 확인 할 수 있습니다.


소스코드

위에서 작성한 소스코드는 아래 주소에서 확인 하실 수 있습니다.


참고

Comments