OpenAPI (Swagger) 形式のyamlからrequestBody/responsesのサンプルJSONを出力する

## はじめに

ユニットテストとかを書くためにAPIのリクエストとレスポンスがJSON形式で欲しいとなったときに、今まではOpenAPIドキュメントをReDocで表示してそこに載っているサンプルをコピー＆JSON形式で保存し直すみたいなことをしていました。
ただしこの方法は、APIの数が増えてきてかつ変更もちょくちょくあるような場合だと結構面倒な作業ですし、CIにも組み込みにくいです。

![スクリーンショット 2021-03-10 21.54.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303105/1a5a7158-9978-dbd9-4c43-214372b50940.png)

わざわざこの画面を経由しないでもOpenAPIドキュメントのyamlからこのrequestBody/responsesだけを直接JSONで吐き出す方法は何か無いものかと色々調べてみたのですが、元のOpenAPIドキュメントをyaml ⇆ JSONで変換する類の話しか見つからなかったので簡単なスクリプトを書くことにしました。
（もしかしたら自分の調査不足なだけで実は既にこの手のことをローカルでやってくれるツールはあるのかもしれません...）

## JSON出力スクリプト

やりたいこととしては以下のような感じです

- コマンドライン引数でOpenAPI (Swagger) 形式のyamlファイルを渡すとそのファイル内の全APIのrequestBody/responsesをJSON形式で出力する
- GETなどrequestBodyが無い場合は空のJSONを出力する
- responsesはステータスコード毎にJSONを出力する

理想はReDocのコピーボタンをクリックしたときにクリップボードに保存されるのと同じような形式のJSONを取得することだったので、当該処理がどうなっているのかソースコードをちょっと読んでみたところ、[openapi-sampler](https://github.com/Redocly/openapi-sampler)というのを使ってそうだということが分かりました。
今回はそれを利用するためにスクリプトはNode.jsで書いています。

完成したスクリプトはこんな感じです。
package.jsonとか含めたコードはGitHubで公開しています。
https://github.com/NatsuToku/openapi-sample-json-generator

```javascript:generator.js
// ファイル出力の設定
const inputFile = process.argv.length == 3 ? process.argv[2] : "openapi.yaml";
const outputBasePath = "./output";
const outputRequestJSONName = "request";
const outputResponseJSONPrefix = "response";
const JSONSpaceNum = 4;

// パース方法の設定
const mediaType = "application/json";
const skipNonRequired = false;
const skipReadOnly = true;
const skipWriteOnly = false;

// モジュールのimport
const SwaggerParser = require("@apidevtools/swagger-parser");
const OpenAPISampler = require("openapi-sampler");
const fs = require("fs");

(async () => {
  // $refポインタを含まないOpenAPI定義のオブジェクトを取得する
  const parser = await SwaggerParser.dereference(inputFile);

  // APIのパス毎に処理する
  Object.keys(parser.paths).forEach(function (path) {
    // 同じパスのメソッド毎に処理する
    Object.keys(parser.paths[path]).forEach(function (method) {
      // ファイル出力用のパスをAPIのパスとメソッドに基づいて設定する
      // ex) パスが /a/b/c でメソッドが POST -> /outputBasePath/a_b_c/post
      const outputPath = `${outputBasePath}/${path
        .replace("/", "")
        .replace(/\//g, "_")}/${method}`;

      // outputBasePath内にファイル出力用のディレクトリを作成する
      fs.mkdir(outputPath, { recursive: true }, (err) => {
        const api = parser.paths[path][method];

        // requestBodyが存在している場合はサンプルJSONオブジェクトを生成する
        let requestSample = {};
        if (
          api.hasOwnProperty("requestBody") &&
          api.requestBody.hasOwnProperty(mediaType)
        ) {
          requestSample = OpenAPISampler.sample(
            api.requestBody.content[mediaType].schema,
            {
              skipNonRequired: skipNonRequired,
              skipReadOnly: skipReadOnly,
              skipWriteOnly: skipWriteOnly,
            }
          );
        }
        // requestBodyのJSONを出力する (requestBodyが存在しない場合は空)
        fs.writeFileSync(
          `${outputPath}/${outputRequestJSONName}.json`,
          JSON.stringify(requestSample, null, JSONSpaceNum)
        );

        // ステータスコード毎に処理する
        const responses = api.responses;
        Object.keys(responses).forEach(function (status) {
          // responseのcontentが存在している場合はサンプルJSONオブジェクトを生成する
          let responseSample = {};
          if (
            responses[status].hasOwnProperty("content") &&
            responses[status].content.hasOwnProperty(mediaType)
          ) {
            responseSample = OpenAPISampler.sample(
              responses[status].content[mediaType].schema,
              {
                skipNonRequired: skipNonRequired,
                skipReadOnly: skipReadOnly,
                skipWriteOnly: skipWriteOnly,
              }
            );
          }
          // responsesのJSONを出力する (responseのcontentが存在しない場合は空)
          fs.writeFileSync(
            `${outputPath}/${outputResponseJSONPrefix}_${status}.json`,
            JSON.stringify(responseSample, null, JSONSpaceNum)
          );
        });
      });
    });
  });
})();
```

## 実行結果

以下のコマンドで実行します。

```sh
node ./generator.js <OPENAPI_FILE_NAME>
```

引数のファイル名は省略するとデフォルトでは「./openapi.yaml」を読むようにしています。
yaml形式でしかテストしてませんがswagger-parserが対応しているのでおそらくJSON形式でも大丈夫だと思われます。

例えば以下のようなyamlを読ませた場合（内容は適当です）

```yaml:openapi.yaml
openapi: 3.0.0
info:
  title: "Book API"
  version: "1.0"
servers:
  - url: "https://xxxxxx.com"
paths:
  /book:
    get:
      summary: Get book list
      description: "本の一覧を取得する"
      responses:
        200:
          description: "本の一覧"
          content:
            application/json:
              schema:
                type: "array"
                items:
                  $ref: "#/components/schemas/BookIndex"
        400:
          description: Bad Request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BadRequest"
    post:
      summary: Create book
      description: "本の情報を新規登録する"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Book"
      responses:
        200:
          description: "OK"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookIndex"
        400:
          description: Bad Request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BadRequest"
  /book/{id}:
    get:
      summary: Get book detail
      description: "本の詳細を取得する"
      parameters:
        - name: id
          in: path
          description: "unique key"
          required: true
          schema:
            type: integer
      responses:
        200:
          description: "本の一覧"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookIndex"
        400:
          description: Bad Request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BadRequest"
    delete:
      summary: Delete book
      description: "本の登録を削除する"
      parameters:
        - name: id
          in: path
          description: "unique key"
          required: true
          schema:
            type: integer
      responses:
        200:
          description: OK
        400:
          description: Bad Request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BadRequest"

components:
  schemas:
    Author:
      type: object
      required:
        - name
      properties:
        name:
          type: string
          example: "test"
        age:
          type: number
          example: 30
        gender:
          type: string
          example: "unknown"
    Book:
      type: object
      required:
        - title
        - price
        - authors
      properties:
        title:
          type: string
          example: "book title"
        price:
          type: number
          example: 1500
        authors:
          type: array
          items:
            $ref: "#/components/schemas/Author"
        category:
          type: "string"
          description: "book category"
          example: "horror"
    BookIndex:
      allOf:
        - type: object
          properties:
            id:
              type: integer
              description: "unique key"
        - $ref: "#/components/schemas/Book"
    BadRequest:
      type: object
      required:
        - message
      properties:
        message:
          type: string
          example: "error"
```

こんな感じに出力されます

```sh
output
├──book
│   ├── get
│   │    ├── request.json
│   │    ├── response_200.json
│   │    └── response_400.json
│   └─── post
│        ├── request.json
│        ├── response_200.json
│        └── response_400.json
└──book_{id}
    ├── delete
    │    ├── request.json
    │    ├── response_200.json
    │    └── response_400.json
    └─── get
         ├── request.json
         ├── response_200.json
         └── response_400.json
```

例えば/bookのPOSTのresponse_200.jsonの中身はこうなってます

```json:response_200.json
{
    "id": 0,
    "title": "book title",
    "price": 1500,
    "authors": [
        {
            "name": "test",
            "age": 30,
            "gender": "unknown"
        }
    ],
    "category": "horror"
}
```

![スクリーンショット 2021-03-10 22.09.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303105/d038194a-c41a-d55a-9abf-ee8f51145a57.png)


## コード内容の説明

```javascript:generator.js
// ファイル出力の設定
const inputFile = process.argv.length == 3 ? process.argv[2] : "openapi.yaml";
const outputBasePath = "./output";
const outputRequestJSONName = "request";
const outputResponseJSONPrefix = "response";
const JSONSpaceNum = 4;

// パース方法の設定
const mediaType = "application/json";
const skipNonRequired = false;
const skipReadOnly = true;
const skipWriteOnly = false;
```

以下の内容を設定しています。

- `inputFile`： OpenAPI (Swagger) 形式のファイル。コマンドライン引数で指定。なければデフォルト「openaip.yaml」
- `outputBasePath`：JSONファイルを出力するディレクトリ
- `outputRequestJSONName`：requestBodyのJSONを出力するときのファイル名。この場合だと「request.json」
- `outputResponseJSONPrefix`：responsesのcontentのJSONを出力するときのファイル名のプレフィックス。この場合だと「response_\<status_code\>.json」
- `JSONSpaceNum`：出力するJSONファイルのインデントのスペース数
- `mediaType`：OpenAPIをパースするときに対象とするメディアタイプ。"application/json"固定で良さそう
- `skipNonRequired`：`required`がtrueのパラメータをスキップする（JSONで出力しない）かどうか
- `skipReadOnly`:`readOnly`がtrueのパラメータをスキップする（JSONで出力しない）かどうか
- `skipWriteOnly`：`writeOnly`がtrueのパラメータをスキップする（JSONで出力しない）かどうか

---

```javascript:generator.js
// モジュールのimport
const SwaggerParser = require("@apidevtools/swagger-parser");
const OpenAPISampler = require("openapi-sampler");
const fs = require("fs");
```

- `@apidevtools/swagger-parser`
  - OpenAPI (Swagger) 形式のファイルを渡すとrefの解決をした状態のオブジェクトを返してくれる
  - 素でyamlファイルを開いてしまうとrefもそのままになってしまうので使用
- `openapi-sampler`
  - OpenAPI Schemaオブジェクトを渡すとサンプルのJSONオブジェクトを返してくれる
  - ReDocでは内部的にこれを利用している模様
- `fs`
  - ファイル関係の操作で使用

---

```javascript:generator.js
(async () => {
  // $refポインタを含まないOpenAPI定義のオブジェクトを取得する
  const parser = await SwaggerParser.dereference(inputFile);
```

swagger-parserがawaitな関数なのでasync functionで囲んだ上で入力ファイルから $refポインタを含まないOpenAPI定義のオブジェクトを取得しています。

---

```javascript:generator.js
  // APIのパス毎に処理する
  Object.keys(parser.paths).forEach(function (path) {
    // 同じパスのメソッド毎に処理する
    Object.keys(parser.paths[path]).forEach(function (method) {
```

OpenAPIのpathsの中の各メソッド毎に処理します。
このforEach内でさらにforEatchを回す書き方はなんとなくもっと良い書き方がある気がしています...

---

```javascript:generator.js
      // ファイル出力用のパスをAPIのパスとメソッドに基づいて設定する
      // ex) パスが /a/b/c でメソッドが POST -> /outputBasePath/a_b_c/post
      const outputPath = `${outputBasePath}/${path
        .replace("/", "")
        .replace(/\//g, "_")}/${method}`;
```

JSONファイルを出力するパスは、OpenAPIのpathsとmethodで決めています。
pathはそのままだと「`/a/b/c`」みたいな形式なので、最初の「/」は消して、残りの「/」は全て「_」に置換することで「 `a_b_c` 」みたいなディレクトリになり、さらにその下にgetやpostと言ったディレクトリを作ります。


---

```javascript:generator.js
      // outputBasePath内にファイル出力用のディレクトリを作成する
      fs.mkdir(outputPath, { recursive: true }, (err) => {
        const api = parser.paths[path][method];
```

先ほど定義したoutputPathのディレクトリを作成します。
recursiveをtrueにすることで再帰的に作成。
既にディレクトリがあるとerrになるのですが、ここではerrが発生してもそのまま握り潰してます。
（のでerr変数はこの後使われない）

---

```javascript:generator.js
        // requestBodyが存在している場合はサンプルJSONオブジェクトを生成する
        let requestSample = {};
        if (
          api.hasOwnProperty("requestBody") &&
          api.requestBody.hasOwnProperty(mediaType)
        ) {
          requestSample = OpenAPISampler.sample(
            api.requestBody.content[mediaType].schema,
            {
              skipNonRequired: skipNonRequired,
              skipReadOnly: skipReadOnly,
              skipWriteOnly: skipWriteOnly,
            }
          );
        }
        // requestBodyのJSONを出力する (requestBodyが存在しない場合は空)
        fs.writeFileSync(
          `${outputPath}/${outputResponseJSONPrefix}_${status}.json`,
          JSON.stringify(requestSample, null, JSONSpaceNum)
        );
```

apiにrequestBodyが含まれかつmediaTypeがapplication/jsonだったらopenapi-samplerでサンプルのオブジェクトを作成し、その後そのオブジェクトをJSONファイルとして出力します。
GETのようにrequestBodyが無いような場合は空のJSONを出力します。
（出力したくなければwriteFileSyncの前に条件判断を入れれば良さそう）

---

```javascript:generator.js
        // ステータスコード毎に処理する
        const responses = api.responses;
        Object.keys(responses).forEach(function (status) {
          // responseのcontentが存在している場合はサンプルJSONオブジェクトを生成する
          let responseSample = {};
          if (
            responses[status].hasOwnProperty("content") &&
            responses[status].content.hasOwnProperty(mediaType)
          ) {
            responseSample = OpenAPISampler.sample(
              responses[status].content[mediaType].schema,
              {
                skipNonRequired: skipNonRequired,
                skipReadOnly: skipReadOnly,
                skipWriteOnly: skipWriteOnly,
              }
            );
          }
          // responsesのJSONを出力する (responseのcontentが存在しない場合は空)
          fs.writeFileSync(
            `${outputPath}/response_${status}.json`,
            JSON.stringify(responseSample, null, JSONSpaceNum)
```

requestBodyと同様にcontentが含まれかつmediaTypeがapplication/jsonだったらopenapi-samplerでサンプルのオブジェクトを作成し、その後そのオブジェクトをJSONファイルとして出力します。
requestBodyとの違いとしてはresponsesはステータスコード毎に複数設定可能であるため、処理もステータスコードをforEatchで回しています。
また、出力するファイル名のサフィックスにステータスコードをくっつけるようにしています。

## 最後に
普段Node.jsはあまり書いてない＋こういうスクリプト的なものは初めて書いたのでコマンドライン引数の取り方とかファイルの保存方法とか個人的には勉強になりました。
Node.jsの書き方のベストプラクティス的なことを把握しないでフィーリングで書いている部分が多いので、ここの書き方微妙だよ的な指摘があればバンバンしていただけるとありがたいです。

## 参考

[OpenAPI Specification - Version 3.0.3 \| Swagger](https://swagger.io/specification/)
[GitHub - Redocly/openapi-sampler: Tool for generation samples based on OpenAPI(fka Swagger) payload/response schema](https://github.com/Redocly/openapi-sampler)
[Swagger 2.0 and OpenAPI 3.0 parser/validator \| Swagger Parser](https://apitools.dev/swagger-parser/)
[Node.jsでコマンドライン引数を取得する - Qiita](https://qiita.com/furusin_oriver/items/f030d1eaa9e7b54233c3)
[[Node.js]ディレクトリの作成と削除をする](https://tech.chakapoko.com/nodejs/file/dir.html)
[How can I pretty-print JSON using node.js? - Stack Overflow](https://stackoverflow.com/questions/5670752/how-can-i-pretty-print-json-using-node-js)