swaggerでAPIドキュメントを書いたらめっちゃはかどった話

Swaggerは、REST APIの仕様とそれに関連するツール群の総称です。REST APIの仕様を定義したJSONファイル(Swagger Spec)を軸に以下のようなツールから構成されています。

  • Swagger UI - Swagger Spec から動的にAPIドキュメントを生成するツール
  • Swagger Editor - Swagger Specのエディタ
  • Swagger Codegen - Swagger Specからクライアントのコードを生成するツール

最近では、Open API InitiativeがAPIの記述のためにSwaggerを採用して話題になりました。

www.publickey1.jp

APIドキュメントのメンテは結構面倒

一般的にAPIの仕様書は、古くはExcel/Wordなどを使ったり、最近ではWikiMarkdown形式で記述したりなどプロジェクトによって色々な方法で記述するのが一般的かなと思います。

このには幾つか問題があって、APIの実装ができてもドキュメントがない状態があったり、せっかくドキュメントを作ってもAPIの修正・追加に追いていない状態になったりすることがよくあります。

そこで今回は、実際に開発が進んでいたCakePHP3で作成したiOSアプリのバックエンドのAPIドキュメントをSwaggerで記述したので、そこで得た知見をもとに実際の記述方法などを解説します。

SwaggerでAPIの仕様を書く

CakePHPのブログチュートリアル(http://book.cakephp.org/3.0/en/tutorials-and-examples/bookmarks/intro.html)で使用するスキーマを若干変更した、以下の様なデータをAPIで扱う場合のドキュメントを書いてみます。

  • User
    • id
    • username
    • password
    • created
    • modified
  • Article
    • id
    • user_id
    • title
    • body
    • created
    • modified
アプリケーションの定義

まずは、今回使用するアプリケーション自体の定義を記述します。以下の様に、@SWG\Info内にアプリケーションの名前、説明、APIバージョンなどの情報を記載します。

後ほど一覧取得APIで共通で使用するPageInfoデータに関してもここに記載します。詳細は後述します。

このファイル自体は、コメントだけなのでどこに置いておいても構わないのですが、今回は app/config/swagger.phpとしておきます。

app/config/swagger.php
<?php
/**
 * @SWG\Swagger(
 *     basePath="/api",
 *     host="api.example.com",
 *     schemes={"https"},
 *     produces={"application/json"},
 *     consumes={"application/json"},
 *     @SWG\Info(
 *         version="1.0.0",
 *         title="Swaggerサンプル",
 *         description="SwaggerサンプルAPI仕様",
 *         @SWG\Contact(name="foo@example.com"),
 *         @SWG\License(name="ライセンス表記")
 *     ),
 *
 *     @SWG\Definition(
 *         definition="PageInfo",
 *         required={"page_count", "current_page", "has_next_page", "has_prev_page", "count", "limit"},
 *         @SWG\Property(
 *             property="page_count",
 *             type="integer",
 *             format="int32",
 *             description="総ページ数"
 *         ),
 *         @SWG\Property(
 *             property="current_page",
 *             type="integer",
 *             format="int32",
 *             description="現在のページ番号"
 *         ),
 *         @SWG\Property(
 *             property="has_next_page",
 *             type="boolean",
 *             description="次ページ有/無"
 *         ),
 *         @SWG\Property(
 *             property="has_prev_page",
 *             type="boolean",
 *             description="前ページ有/無"
 *         ),
 *         @SWG\Property(
 *             property="count",
 *             type="integer",
 *             format="int32",
 *             description="ページ内アイテム数"
 *         ),
 *         @SWG\Property(
 *             property="limit",
 *             type="integer",
 *             format="int32",
 *             description="ページ内最大アイテム数"
 *         )
 *     )
 * )
 */
Modelの定義

CakePHP3では、エンティティの内容を明示的に宣言しないので今回は、各Entityクラスのヘッダーに定義を記述しました。実際の記述は以下のような感じになります。

Model/Entity/User.php
<?php
namespace App\Model\Entity;

use Cake\ORM\Entity;

/**
 * User Entity.
 *
 * @SWG\Definition(
 *      definition="User",
 *      required={
 *          "id", "username", "password", "created", "modified"
 *      },
 *      @SWG\Property(
 *          property="id",
 *          type="integer",
 *          description="ユーザid"
 *      ),
 *      @SWG\Property(
 *          property="username",
 *          type="string",
 *          description="ユーザ名",
 *          maxLength=255,
 *      ),
 *      @SWG\Property(
 *          property="password",
 *          type="string",
 *          description="パスワード",
 *          maxLength=255,
 *      ),
 *      @SWG\Property(
 *          property="created",
 *          type="datetime",
 *          description="作成日時"
 *      ),
 *      @SWG\Property(
 *          property="modified",
 *          type="datetime",
 *          description="更新日時"
 *      )
 * )
 */
class User extends Entity
{
...
}
Model/Entity/Article.php
<?php
namespace App\Model\Entity;

use Cake\ORM\Entity;

/**
 * Article Entity.
 *
 * @SWG\Definition(
 *      definition="Article",
 *      required={
 *          "id", "user_id", "title", "body", "created", "modified"
 *      },
 *      @SWG\Property(
 *          property="id",
 *          type="integer",
 *          description="アーティクルid"
 *      ),
 *      @SWG\Property(
 *          property="user_id",
 *          type="integer",
 *          description="ユーザid"
 *      ),
 *      @SWG\Property(
 *          property="title",
 *          type="string",
 *          description="タイトル",
 *			maxLength=255
 *      ),
 *      @SWG\Property(
 *          property="body",
 *          type="string",
 *          description="記事本文"
 *      ),
 *      @SWG\Property(
 *          property="created",
 *          type="datetime",
 *          description="作成日時"
 *      ),
 *      @SWG\Property(
 *          property="modified",
 *          type="datetime",
 *          description="更新日時"
 *      )
 * )
 */
class Article extends Entity
{
...
}
APIの定義

APIで使うモデルの定義が完了したので、次に実際のAPIの定義を記述します。APIの定義は各コントローラのアクションのコメントに記述します。すべてのAPIの記述方法を書くのとなかなかの量になるので、特徴的な記述を使うことが多い一覧系のAPI仕様の書き方について説明します。

今回は、CakePHP3+Crud Pluginを使用しましたので、一覧取得時の実際のレスポンスは以下の様な形式になります。

CakePHP3+Crud Pluginに関しては id:skywaker さんの以下の記事が詳しいです。
slywalker.hateblo.jp

{
    "success": true,		// boolean: 処理結果
    "data": [			// [Entity]: エンティティの配列
        {
            "id": "1",
            "username": 'test',
            "pasword": "xxxx",
            "created": "2016-01-01 01:01:01"
        }
    ],					// PageInfo: ページング情報
    "pagination": {
        "page_count": 1,
        "current_page": 1,
        "has_next_page": false,
        "has_prev_page": false,
        "count": 1,
        "limit": 20
    }
}

*** Controller/UsersController.php

/api/users.json はユーザ一覧を取得するAPIで20件ごとにページングされ、検索文字列を指定することで、ユーザ名で検索できる仕様とします。

レスポンスコードごとに@SWG\Responseブロックを記述してレスポンス内容の解説と、どんなデータが帰るかを記述します。

@SWG\Propertytypearrayとして記述し、参照先のモデルを@SWG\Itemsで参照することができます。また、プロパティ自体が、定義済みのモデルを使用する場合は、ref="#/definitions/PageInfo"の様に記述することで参照できます。

<?php
namespace App\Controller\Api;

use Cake\Event\Event;
use Cake\ORM\TableRegistry;

class UsersController extends ApiController
{
	...
    /**
     * Index method
     *
     * @return void
     * @SWG\Get(
     *      path="/users",
     *      description="ユーザ一覧",
     *      produces={"application/json"},
     *      @SWG\Parameter(
     *          description="検索文字列",
     *          in="path",
     *          name="q",
     *          required=false,
     *          type="string"
     *      ),
     *      @SWG\Response(
     *          response=200,
     *          description="取得成功",
     *          @SWG\Schema(
     *              @SWG\Property(
     *                  property="status",
     *                  type="boolean",
     *                  description="処理結果",
     *              ),
     *              @SWG\Property(
     *                  property="data",
     *                  type="array",
     *                  @SWG\Items(ref="#/definitions/User"),
     *                  description="ユーザ情報",
     *              ),
     *              @SWG\Property(
     *                  property="pagination",
     *                  ref="#/definitions/PageInfo",
     *                  description="ページ情報",
     *              ),
     *          )
     *      ),
     *      @SWG\Response(
     *          response="401",
     *          description="Unauthorized"
     *      ),
     * )
     */
    public function index()
    {
    	...
    }

*** Controller/ArticlesController.php

api/articles.jsonは、記事の一覧を返すAPIを想定しています。api/users.jsonと違い、記事のデータだけでなく記事を投稿したユーザを含む形で以下の様なレスポンスを返すことにします。

{
    "success": true,		// boolean: 処理結果
    "data": [			// [Article]: 記事の配列
        {
            "id": "1",
            "user_id": 1,
            "title": 'test title',
            "body": "test body",
            "created": "2016-01-01 01:01:01"
            "user": {
	            "id": "1",
	            "username": 'test',
	            "pasword": "xxxx",
	            "created": "2016-01-01 01:01:01"
	        }
        }
    ],					// PageInfo: ページング情報
    "pagination": {
        "page_count": 1,
        "current_page": 1,
        "has_next_page": false,
        "has_prev_page": false,
        "count": 1,
        "limit": 20
    }
}

記事一覧のレスポンスのdata内で定義しているItemは先ほど定義したArticleではなく、ユーザ情報を含む形で定義した、IndexArticleを指定しています。

IndexArticleの定義は allOfを使うことで定義済みのArticleモデルの情報を再利用して、userプロパティを追加しています。

この様に、@ref,@SWG\ItemsallOfなどを使用して定義済みのモデルを参照することで、個別のAPIごとにレスポンス内容が微妙み異なる場合などに記述量をかなり減らすことができます。

<?php
namespace App\Controller\Api;

use Cake\Event\Event;
use Cake\ORM\TableRegistry;

class ArticleController extends ApiController
{
	...
    /**
     * Index method
     *
     * @return void
     * @SWG\Get(
     *      path="/articles",
     *      description="記事一覧",
     *      produces={"application/json"},
     *      @SWG\Parameter(
     *          description="検索文字列",
     *          in="path",
     *          name="q",
     *          required=false,
     *          type="string"
     *      ),
     *      @SWG\Response(
     *          response=200,
     *          description="取得成功",
     *          @SWG\Schema(
     *              @SWG\Property(
     *                  property="status",
     *                  type="boolean",
     *                  description="処理結果",
     *              ),
     *              @SWG\Property(
     *                  property="data",
     *                  type="array",
     *                  @SWG\Items(ref="#/definitions/IndexArticle"),
     *                  description="記事",
     *              ),
     *              @SWG\Property(
     *                  property="pagination",
     *                  ref="#/definitions/PageInfo",
     *                  description="ページ情報",
     *              ),
     *          )
     *      ),
     *      @SWG\Response(
     *          response="401",
     *          description="Unauthorized"
     *      ),
     * )
     *
     * @SWG\Definition(
     *      definition="IndexArticle",
     *      allOf={
     *          @SWG\Schema(ref="#/definitions/Article"),
     *      },
     *      @SWG\Property(
     *          property="user",
     *          description="ユーザ情報",
     *          ref="#/definitions/User"
     *      )
     * )
     */
    public function index()
    {
    	...
    }

swagger-phpのインストール

APIの定義の記述が終わったら、記述したアノテーションを解析するツール swagger-php をインストールしてSpecを生成します。composerでインストールできるので、いつものようにこんな感じですね。

$ composer require --dev zircote/swagger-php

Specファイルの生成

アプリケーションのルートで以下の様な感じでswagger-phpを実行します。-o で出力先のディレクトリを指定できるので適宜環境に合わせて指定してください。今回は、Specファイルは後述のswagger-uiで参照しますので、ブラウザからアクセスできる必要があります。

$ ./app/vendor/bin/swagger ./app/src/ -o ./app/webroot/api-documents/

swagger-uiのインストール

Specファイルを解析して、APIドキュメントとして表示する swagger-ui は以下で公開されています。

https://github.com/swagger-api/swagger-ui

このリポジトリの `dist` ディレクトリの内容をブラウザからアクセスできる場所に適宜コピーしてください。

swagger-uiの設定を修正

設置したswagger-ui内のindex.htmlの以下の箇所を、先ほど作成したSpecファイルを参照するように修正します。

var url = window.location.search.match(/url=([^&]+)/);
if (url && url.length > 1) {
  url = decodeURIComponent(url[1]);
} else {
  url = "http://petstore.swagger.io/v2/swagger.json"; // ここを先ほど作成したSpecファイルのURLに修正
}

問題なく生成されれば以下のように、APIドキュメントが参照できます。



f:id:kaz_29:20160218112002p:plain

f:id:kaz_29:20160218112015p:plain

今回作成したファイルは gistで公開しましたので swagger-uiで実際にAPIドキュメントをみられます

まとめ

APIドキュメント作成は、なかなかに気の重い作業という人も多いと思いますがswaggerを使うとかなり手間を減らせる上に、実際のコード内に仕様を記述するため、APIが更新されたのにドキュメントが更新されないといった問題も防ぎやすくなると思います。また、CIに組み込んでリポジトリの更新時に自動生成・公開するようにすることも簡単にできますので、APIの実装が完了したらAPIドキュメントも自動で公開するみたいなこともできますね。

チームで効率良く開発を進めるには、必要なドキュメントをいかに効率良く作成するかがとても重要だと思うので是非swaggerを試してみてください!