Spring REST Docs によるAPI仕様書自動化

こんにちは。サーバサイドエンジニアをしている大堀です。

今回は、私が所属しているプロジェクトで導入しているSpring REST Docsによる、
API仕様書自動化方法に関して紹介します。

導入するに至った背景

私が今までのプロジェクトでは、APIドキュメントを手動で作成していましたが、
記入ミスや、時間が経つにつれて更新が滞ったり、実装と異なってくるという問題が生じていました。

そこで、Spring MVCの枠組みで作成されたアプリケーションから、
自動でドキュメントを生成してくれるSpring REST Docsを導入することにしました。

Spring REST Docsについて

Spring REST DocsはRESTfulなサービスのドキュメントを生成するツールです。

Spring MVC Testで書かれたテストを通った場合、HTTPリクエストやレスポンス等が記載されたsnippetsをasciidoctorで生成します。
テストを通らなかった場合は、ドキュメントは生成されません。

asciidoctorはhtmlを生成することができ、また、必要に応じてUIをカスタマイズすることができます。

Spring REST Docsを導入することで、以下のメリットがあります。

  • API仕様書を自動作成するため、仕様書がコードと同様に保たれる。また作成の手間も省ける。
  • 見やすいAPI仕様書が手軽に作成でき、またUIのカスタマイズも容易である。

利用方法について

次に、Spring REST Docsの導入方法と利用方法を紹介します。

インストール

Spring REST Docsをインストールするには、pom.xmlに以下を追記します。

<properties>
    <snippetsDirectory>${project.build.directory}/generated-snippets</snippetsDirectory>
</properties>

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-site-plugin</artifactId>
    <version>3.2</version>
    <dependencies>
        <dependency>
        <groupId>org.asciidoctor</groupId>
        <artifactId>asciidoctor-maven-plugin</artifactId>
        <version>1.5.2</version>
        </dependency>
    </dependencies>
</plugin>

<plugin>
    <groupId>org.asciidoctor</groupId>
    <artifactId>asciidoctor-maven-plugin</artifactId>
    <version>1.5.2</version>
    <executions>
        <execution>
            <id>generate-docs</id>
            <phase>package</phase> ー①
            <goals>
            <goal>process-asciidoc</goal>
            </goals>
            <configuration>
            <preserveDirectories>true</preserveDirectories> ー②
            <backend>html</backend> ー③
            <doctype>book</doctype>
            <attributes>
            <stylesheet>${basedir}/src/main/asciidoc/asciidoc.css</stylesheet> ー④
            <snippets>${project.build.directory}/generated-snippets</snippets> ー⑤
            </attributes>
            </configuration>
        </execution>
    </executions>
</plugin>

<dependency>
    <groupId>org.springframework.restdocs</groupId>
    <artifactId>spring-restdocs-mockmvc</artifactId>
    <version>1.0.2.BUILD-SNAPSHOT</version>
    <scope>test</scope>
</dependency>

上記pom.xmlについて説明します。

  1. いつドキュメントを生成するか指定します。今回はパッケージング時に生成しています。
  2. asciidocのディレクトリ構成と同じディレクトリ構成でhtml格納ディレクトリを生成します。
  3. ドキュメントの出力形式を指定します。今回はhtml形式で出力しています。
  4. デフォルトで読み込むcssを指定します。
  5. 自動生成するasciidoc(request header,response header,request body, response body等毎)の格納フォルダを指定します。

テストを書く 

APIドキュメントを生成するためのテストを書きます。

テストを通った時にgenerated-snippets配下へasciidocファイルが生成されます。
テストを通らないと生成されないので頑張って通します。

以下が、テスト例となります。

public class RestDocsSampleTest extends ApiTestBase {

    /**
     * 初期設定
     * 
     * @throws Exception
     */
    @Before
    public void buildMockMvcBefore() throws Exception {
        super.before(); ー①
    }

    /**
     * GetAPI仕様書生成例
     * 
     * @throws Exception
     */
    @Test
    public void testRequestMapping() throws Exception { ー②
        // Snippetへ記載されるリクエストボディ
        // リクエストで使用していて仕様書へ記載する必要ないフィールドは末尾へ.ignore()を追記。(しないとエラーが出ます)
        List<FieldDescriptor> responseFieldDescriptorList = super.addBaseResponseFieldList();
        responseFieldDescriptorList.add(fieldWithPath("userId").description("The persons' ID")); ー③
            responseFieldDescriptorList.add(fieldWithPath("name").description("The persons' last name")).ignored());ー④

            // Snippetを作成します
            // Headerの内容を仕様書へ記載したくない場合はcreateHeaderSnippetsBooleanをfalseにして渡して下さい。
            super.createResponseFieldSnippets(responseFieldDescriptorList, super.createHeaderSnippetsBoolean);

        // Getリクエスト正常テスト
        super.getRequest("/hoge/fuga/1");
    }

    /**
     * PostAPI仕様書生成例
     * 
     * @throws Exception
     */
    @Test
    public void testPostRequestMapping() throws Exception {
        // Snippetへ記載されるリクエストボディ
        // リクエストで使用していて仕様書へ記載する必要ないフィールドは末尾へ.ignore()を追記。(しないとエラーが出ます)
        List<FieldDescriptor> requestFieldDescriptorList = super.addBaseRequestFieldList();
        requestFieldDescriptorList.add(fieldWithPath("userId").description("The persons' ID"));
        requestFieldDescriptorList.add(fieldWithPath("name").description("The persons' ID").ignored());

        // Snippetへ記載するレスポンスボディ
        // レスポンスで使用していて仕様書へ記載する必要ないフィールドは末尾へ.ignore()を追記。(しないとエラーが出ます)
        List<FieldDescriptor> responseFieldDescriptorList = super.addBaseResponseFieldList();
        responseFieldDescriptorList.add(fieldWithPath("userId").description("The persons' ID"));
        responseFieldDescriptorList.add(fieldWithPath("name").description("The persons' last name").type("HogeObject")); ー⑤

            // Snippetを作成します
            // Headerの内容を仕様書へ記載したくない場合はcreateHeaderSnippetsBooleanをfalseにして渡して下さい。
            super.createRequestAndResponseFieldSnippets(requestFieldDescriptorList, responseFieldDescriptorList, !super.createHeaderSnippetsBoolean);

        SampleRequest sampleRequest = new SampleRequest();
        sampleRequest.setUserId(1);
        sampleRequest.setName("taro");

        // Postリクエスト正常テスト
        super.postRequest("/hoge/fuga/1", sampleRequest);
    }
}

上記コードについて説明します。

  1. asciidoc出力設定を継承元クラスで行っています。継承元クラスは下部に記載があります。
  2. API仕様書を作成するメソッド名はアルファベットで指定します。日本語でも指定できますが、asciidocが格納されるフォルダ名になるため、アルファベットが望ましいかと思います。
  3. 自動で生成されるAPI仕様書には以下が記載できます
  • fieldWithPath:フィールド名(Null不可)
  • description:フィールド説明(Null不可)
  • type:フィールド型
  • optional:descriptionやtypeを上書き
  • ignore:仕様書へ記載しないフィールド(仕様書へ記載しないフィールドも、フィールド名と説明は設定した上でこのメソッドを付与する必要があります)
  1. リクエストやレスポンスのフィールドはAPI仕様書へ記載するしないに関わらず全てテスト上へ書いておく必要があります。書いていない場合エラーが出力されます。
  2. リクエストフィールドやレスポンスフィールドの型は自動で読み込まれますが、独自型を設定している場合はtypeメソッドへStringで記載することで仕様書への出力が可能です。

ApiTestBase.Java

asciidocが生成されるフォルダの命名規則や出力フォーマットなどをまとめてあります。

<br />@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("classpath:TestApplicationContext.xml")
@WebAppConfiguration
public class ApiTestBase {

    @Rule
    public final RestDocumentation restDocumentation = new RestDocumentation("target/generated-snippets");

    @Resource(name = "jsonObjectMapperFactory")
        protected ObjectMapper jsonObjectMapper;

    protected RestDocumentationResultHandler document;

    protected MockMvc mockMvc;

    protected boolean createHeaderSnippetsBoolean = true;

    /**
     * 初期設定
     *
     * @throws Exception
     */
    public void before() throws Exception {
        this.document = document("{class-name}/{method-name}", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()));
        mockMvc = builder.apply(documentationConfiguration(this.restDocumentation)).alwaysDo(this.document).build();
    }

    private List<HeaderDescriptor> addBaseRequestHeaderList() {
        List<HeaderDescriptor> requestHeaderDescriptorList = new ArrayList<>();

        requestHeaderDescriptorList.add(headerWithName("X-Hoge").description("hoge"));

        return requestHeaderDescriptorList;
    }

    private List<HeaderDescriptor> addBaseResponseHeaderList() {
        List<HeaderDescriptor> responseHeaderDescriptorList = new ArrayList<>();

        responseHeaderDescriptorList.add(headerWithName("X-Fuga").description("fuga"));

        return responseHeaderDescriptorList;
    }

    protected List<FieldDescriptor> addBaseRequestFieldList() {
        List<FieldDescriptor> requestFieldDescriptorList = new ArrayList<>();

        return requestFieldDescriptorList;
    }

    protected List<FieldDescriptor> addBaseResponseFieldList() {
        List<FieldDescriptor> responseFieldDescriptorList = new ArrayList<>();

        responseFieldDescriptorList.add(fieldWithPath("statusCode").ignored());

        return responseFieldDescriptorList;
    }

    public void createRequestFieldSnippets(List<FieldDescriptor> requestFieldDescriptorList, boolean createHeaderSnippetsBoolean) {
        his.createRequestAndResponseFieldSnippets(requestFieldDescriptorList, null, createHeaderSnippetsBoolean);
    }

    public void createResponseFieldSnippets(List<FieldDescriptor> responseFieldDescriptorList, boolean createHeaderSnippetsBoolean) {
        this.createRequestAndResponseFieldSnippets(null, responseFieldDescriptorList, createHeaderSnippetsBoolean);
    }

    public void createRequestAndResponseFieldSnippets(List<FieldDescriptor> requestFieldDescriptorList, List<FieldDescriptor> responseFieldDescriptorList, boolean createHeaderSnippetsBoolean) {
        List<Snippet> snippetList = new ArrayList<>();

        // Headerも仕様書へ出力する
        if (createHeaderSnippetsBoolean) {
            snippetList.add(requestHeaders(this.addBaseRequestHeaderList().toArray(new HeaderDescriptor[0])));
            snippetList.add(responseHeaders(this.addBaseResponseHeaderList().toArray(new HeaderDescriptor[0])));
        }

        // リクエスト、レスポンスをテーブルへ出力する
        if (requestFieldDescriptorList != null && responseFieldDescriptorList != null) {
            snippetList.add(requestFields(requestFieldDescriptorList.toArray(new FieldDescriptor[0])));
            snippetList.add(responseFields(responseFieldDescriptorList.toArray(new FieldDescriptor[0])));
        } else if (requestFieldDescriptorList != null) {
            snippetList.add(requestFields(requestFieldDescriptorList.toArray(new FieldDescriptor[0])));
        } else if (responseFieldDescriptorList != null) {
            snippetList.add(responseFields(responseFieldDescriptorList.toArray(new FieldDescriptor[0])));
        }

        this.document.snippets(snippetList.toArray(new Snippet[0]));
    }

    public void getRequest(String uri) throws Exception {
        this.mockMvc.perform(
                get(uri)
                .header("X-Hoge", "1")
                .accept(MediaType.APPLICATION_JSON)
                )
            .andExpect(status().isOk());
    }

    public void postRequest(String uri, Object object) throws Exception {
        this.mockMvc.perform(
                post(uri)
                .header("X-Hoge", "1")
                .contentType(MediaType.APPLICATION_JSON)
                .content(this.jsonObjectMapper.writeValueAsString(object))
                .accept(MediaType.APPLICATION_JSON)
                )
            .andExpect(status().isOk());
    }
}

base.adoc設定

生成されたasciidocを繋ぎ合わせるasciidocです。
プロジェクトでは、base.adocを元に、
テストから生成されたasciidoc(curl-request.adoc,http-request.adocなど)を繋ぎ合わせたasciidocをシェルスクリプトで作成しています。

= RESTful Notes API Guide

Sample;
:doctype: book
:icons: font
:source-highlighter: highlightjs
:sectlinks:

=== About API

API概要

.リクエスト構造

include::{snippets}/controller-name/method-name/curl-request.adoc[]

.リクエスト例

include::{snippets}/controller-name/method-name/http-request.adoc[]

.レスポンス例

include::{snippets}/controller-name/method-name/http-response.adoc[]


.リクエストヘッダー例

include::{snippets}/controller-name/method-name/request-headers.adoc[]


.レスポンスヘッダー例

include::{snippets}/controller-name/method-name/response-headers.adoc[]


.リクエストフィールド例

include::{snippets}/controller-name/method-name/request-fields.adoc[]


.レスポンスフィールド例

include::{snippets}/controller-name/method-name/response-fields.adoc[] 

パッケージング

パッケージングによってgenerate-docs配下へhtmlが出力されます。

mvn package

下記のような仕様書が出力されます。

springrestdocs1
springrestdocs2

JenkinsによるAPIドキュメント生成の自動化

Jenkinsビルド時に、自動でテストが走るようになっているため、
このタイミングで(テストが通っていれば)APIドキュメントも作成されます。

現在は、成果物としてAPIドキュメントを出力しているだけですが、
今後、ドキュメントをどこかのサーバーにアップロードして、Javaの実行環境がなくても
APIドキュメントを読めるようにすることを検討しています。

まとめ

Spring REST Docsを導入するに至った背景と、導入方法例に関して紹介しました。

当ブログ執筆時点ではプロジェクトが立ち上がったばかりでAPI仕様書は多く作成していませんが、Spring REST Docsを導入したことで仕様書作成の手間が減ったと感じています。

参考になれば幸いです。

参考