神ツール【Mockoon】との出会い　〜レスポンス柔軟なモックAPI開発〜

今からモック API サーバを 1 日で作れって言われたら、皆さんならどうしますか？

# はじめに

先日、Web バックエンド開発のプロジェクト新規立上げの際に、基本設計や環境整備を進める中で、
モック API サーバ（OpenAPI 形式）のツール選定・設計・実装を行う場面がありました。
また案件の諸事情で、1〜2 日である程度形になったものを整え切る必要がありました。

選定時の苦悩や、最終的に Mockoon というツールに辿り着き課題をクリアするまでの軌跡をここに記したいと思います。

# そもそもの要件

やりたかったことはこんな感じ：
(※前提として、ここでのモックはバックエンド → 外部 API のモックについてであり、フロントエンド → バックエンドのモックについてではありません。)

- モックサーバは Docker 化して環境配布容易な状態にしておきたい
  - ローカルだけでなく、AWS の dev や stg など複数環境上にモックサーバを立てたいため
- OpenAPI（Swagger）やそれに近い形の YAML、JSON を元にモックサーバを起動したい
  - [Prism](https://docs.stoplight.io/docs/prism) のように OpenAPI の定義を元に値を返してくれるような構造にしたい気持ちがあった
- モックのレスポンスは固定ではなくリクエストの中身によって変えたい
  - リクエストヘッダやボディ、クエリパラメータを条件にレスポンスを分岐させたい
    - Swagger の examples 値を出し分けるなどの方法を検討
- テスト内容に応じて、モックのリクエスト条件やレスポンスのデータを即時・柔軟に書き換えられるようにしたい
  - バックエンドやモックサーバのロジックに、リクエスト条件やデータが張り付きにならないようにしたい
    - リクエスト条件やデータは Swagger や JSON で管理し、ロジックと分離したい
- できれば GUI で API 定義を行いたい
  - フロントエンド出身者が多い案件のため、Swagger や JSON を手でガリガリ書くのは辛い可能性がある
  - Swagger 筋力がなくてもある程度実装が困らないように GUI という手段も用意できるならしたい気持ちがあった

# Prism を検討するも不採用

まず初めに採用を検討したのが Prism でした。
しかし、Prism での課題が主に 2 つありました：

- 柔軟なレスポンス出し分けが難しい点
- API 定義用の GUI ツールについて痒い所に手が届くものが見つからなかった点

## Prism のレスポンス出し分け方法

Prism で提供されているレスポンスの出し分け方法は以下の 2 つで、
今回要件のリクエストヘッダやボディ、クエリパラメータによってレスポンスを分岐させるような機能は提供されていませんでした。

1. `Prefer: example=exampleKey`ヘッダーにより、複数の example 値を出し分ける方法
   - 参考：[Prism - HTTP Mocking](https://docs.stoplight.io/docs/prism/83dbbd75532cf-http-mocking)
2. Faker を利用して動的なランダムレスポンスを生成・返却する方法
   - 参考：[Prism - Dynamic Response Generation with Faker](https://docs.stoplight.io/docs/prism/9528b5a8272c0-dynamic-response-generation-with-faker)

`Prefer` ヘッダによるレスポンス出し分けを採用する場合、以下の課題が想定されました。

- バックエンドのロジックに、モックへのリクエスト条件が張り付きになってしまう
- 本番用とモック用にリクエストヘッダを記述し分ける必要がある（本番向けのリクエストには `Prefer` は不要なため）
- テスト内容に応じて、モックのリクエスト条件やレスポンスのデータを即時・柔軟に書き換えることが難しい
  - いちいち `Prefer` ヘッダ修正 & バックエンドコンテナのリビルド・デプロイ & Swagger 修正・デプロイが必要

```sh
# 本番用リクエスト
GET /api/users
Authorization: Bearer xxx

# モック用リクエスト
GET /api/users
Authorization: Bearer xxx
Prefer: example=adminUser  # 余計なヘッダーが必要
```

## OpenAPI 定義作成用の GUI ツール

有償利用であれば Prism との親和性が高い Spotlight Studio や、apidoc などが良さげなのですが、無償利用だとなかなかこれというものが見つからず、、、

[こちらの記事](https://zenn.dev/dozo/articles/9a70e5b116f7e6) を拝読し、
[openapi-gui](https://github.com/Mermade/openapi-gui) は良さそうかもと思い触ってみたのですが、
API path や HTTP ステータスなど大まかな部分は定義できるものの、なんと examples 値を定義できなかったため、採用を断念。。。

# そして救世主 Mockoon に出会う

そんなとき、[Mockoon](https://mockoon.com/) に辿り着きました。
Mockoon の概要説明（参考：[What is Mockoon?](https://mockoon.com/what-is-mockoon/)）を下記に引用します:

<details>
<summary>"What is Mockoon?"</summary>

> Mockoon is a popular open-source API mocking tool created in 2017 by Guillaume Monnet. It allows developers to quickly create mock APIs and test their applications without relying on third-party APIs that can be unreliable, slow, or expensive to use in development and testing environments.
>
> Mockoon is a desktop application available on the major operating systems accompanied by a CLI, Docker image, and several libraries to help developers integrate their API mocks into their existing workflows, servers, and CI/CD pipelines.
> Mockoon is easy to use, fast, and reliable. It is built with modern web technologies and is constantly updated with new features and improvements.
>
> Mockoon is used by thousands of developers and companies around the world to speed up their development process and reduce dependencies on external services. It has been downloaded more than 700k times and has a vibrant community of contributors and users.

</details>

# Mockoon の良い点

1. **レスポンスを柔軟に設定できる**

   - リクエストヘッダやボディ、クエリパラメータによるレスポンスの分岐が可能

2. **構築・運用が楽**

   - [公式 Docker イメージ](https://hub.docker.com/r/mockoon/cli)もあってコンテナ化が容易
   - モック API のリクエスト・レスポンス定義は JSON ファイル 1 個で完結し、バックエンドロジックと分離できる

3. **GUI もある**
   - [Mockoon Desktop](https://mockoon.com/download/)で API 定義 JSON を GUI で作成可
   - Mockoon の JSON <-> OpenAPI 形式の変換が可能
     - 参考：[Import/export in OpenAPI format](https://mockoon.com/docs/latest/openapi/import-export-openapi-format/)

# Mockoon サンプル API 実装例

雑ですが Docker 版 Mockoon でサンプル API を作ってみました。
リクエストヘッダ、ボディ、クエリパラメータに応じてレスポンスの出し分けを最低限確認できるものにしています。
後述の実装解説も参考にしていただければと思います。

<details>
<summary>docker-compose.yml</summary>

```yaml
services:
  mock:
    image: mockoon/cli:latest
    volumes:
      - mockoon.json:/data/mockoon.json
    ports:
      - "3030:3030" # 任意のポート
    command: --data /data/mockoon.json --port 3030 --repair
```

</details>

<details>
<summary>mockoon.json (ファイル名は任意)</summary>

```json
{
  "uuid": "hello-world-api",
  "name": "Hello World API",
  "endpointPrefix": "sample",
  "port": 3030,
  "routes": [
    {
      "uuid": "get-hello-world",
      "method": "get",
      "endpoint": "helloworld",
      "responses": [
        {
          "uuid": "admin-ja",
          "body": "{\n  \"status\": \"success\",\n  \"message\": \"こんにちは, admin!\",\n  \"data\": {\n    \"role\": \"admin\",\n    \"permissions\": [\"read\", \"write\", \"admin\"],\n    \"language\": \"ja\"\n  }\n}",
          "statusCode": 200,
          "label": "Admin JA Response",
          "headers": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "rulesOperator": "AND",
          "rules": [
            {
              "target": "header",
              "modifier": "Authorization",
              "value": "Bearer admin-token",
              "operator": "equals"
            },
            {
              "target": "query",
              "modifier": "lang",
              "value": "ja",
              "operator": "equals"
            }
          ]
        },
        {
          "uuid": "admin-en",
          "body": "{\n  \"status\": \"success\",\n  \"message\": \"Hello, admin!\",\n  \"data\": {\n    \"role\": \"admin\",\n    \"permissions\": [\"read\", \"write\", \"admin\"],\n    \"language\": \"en\"\n  }\n}",
          "statusCode": 200,
          "label": "Admin EN Response",
          "headers": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "rulesOperator": "AND",
          "rules": [
            {
              "target": "header",
              "modifier": "Authorization",
              "value": "Bearer admin-token",
              "operator": "equals"
            },
            {
              "target": "query",
              "modifier": "lang",
              "value": "en",
              "operator": "equals"
            }
          ]
        },
        {
          "uuid": "user-ja",
          "body": "{\n  \"status\": \"success\",\n  \"message\": \"こんにちは, user!\",\n  \"data\": {\n    \"role\": \"user\",\n    \"permissions\": [\"read\"],\n    \"language\": \"ja\"\n  }\n}",
          "statusCode": 200,
          "label": "User JA Response",
          "headers": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "rulesOperator": "AND",
          "rules": [
            {
              "target": "header",
              "modifier": "Authorization",
              "value": "Bearer user-token",
              "operator": "equals"
            },
            {
              "target": "query",
              "modifier": "lang",
              "value": "ja",
              "operator": "equals"
            }
          ]
        },
        {
          "uuid": "user-en",
          "body": "{\n  \"status\": \"success\",\n  \"message\": \"Hello, user!\",\n  \"data\": {\n    \"role\": \"user\",\n    \"permissions\": [\"read\"],\n    \"language\": \"en\"\n  }\n}",
          "statusCode": 200,
          "label": "User EN Response",
          "headers": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "rulesOperator": "AND",
          "rules": [
            {
              "target": "header",
              "modifier": "Authorization",
              "value": "Bearer user-token",
              "operator": "equals"
            },
            {
              "target": "query",
              "modifier": "lang",
              "value": "en",
              "operator": "equals"
            }
          ]
        },
        {
          "uuid": "unauthorized",
          "body": "{\n  \"status\": \"error\",\n  \"code\": \"UNAUTHORIZED\",\n  \"message\": \"Hello... but who are you? Invalid token.\"\n}",
          "statusCode": 401,
          "label": "Unauthorized Error",
          "headers": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "rules": [
            {
              "target": "header",
              "modifier": "Authorization",
              "operator": "null"
            }
          ]
        },
        {
          "uuid": "default-response",
          "body": "{\n  \"status\": \"error\",\n  \"code\": \"INVALID_PARAMETERS\",\n  \"message\": \"Invalid parameters or token provided.\"\n}",
          "statusCode": 400,
          "label": "Default Error Response",
          "headers": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "default": true
        }
      ]
    },
    {
      "uuid": "post-hello-world",
      "method": "post",
      "endpoint": "helloworld",
      "responses": [
        {
          "uuid": "bad-request",
          "body": "{\n  \"status\": \"error\",\n  \"code\": \"BAD_REQUEST\",\n  \"message\": \"Hello...名前を入力してください\"\n}",
          "statusCode": 400,
          "label": "Bad Request Error",
          "headers": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "rules": [
            {
              "target": "body",
              "modifier": "name",
              "operator": "null"
            }
          ]
        },
        {
          "uuid": "system-error",
          "body": "{\n  \"status\": \"error\",\n  \"code\": \"SYSTEM_ERROR\",\n  \"message\": \"Hello...システムエラーが発生しました\"\n}",
          "statusCode": 500,
          "label": "System Error",
          "headers": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "rules": [
            {
              "target": "body",
              "modifier": "name",
              "value": "error",
              "operator": "equals"
            }
          ]
        },
        {
          "uuid": "success-response",
          "body": "{\n  \"status\": \"success\",\n  \"message\": \"Hello, {{body 'name'}}さん!\"\n}",
          "statusCode": 200,
          "label": "Success Response",
          "headers": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "default": true
        }
      ]
    }
  ]
}
```

</details>

# Mockoon サンプル API 動作確認

ご参考までに、サンプル API を叩くとどのようなレスポンスが返ってくるのかも載せておきます。

## GET sample/helloworld

リクエストヘッダ、クエリパラメータを利用したレスポンス例はこちらです。

### 200 success

```sh
# リクエスト1
curl -i -H "Authorization: Bearer admin-token" "http://mock:3030/sample/helloworld?lang=en"
```

<details>
<summary>リクエスト1 - レスポンス</summary>

```sh
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 161
Date: Tue, 17 Dec 2024 04:43:52 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{
  "status": "success",
  "message": "Hello, admin!",
  "data": {
    "role": "admin",
    "permissions": ["read", "write", "admin"],
    "language": "en"
  }
}
```

</details>

```sh
# リクエスト2
curl -i -H "Authorization: Bearer admin-token" "http://mock:3030/sample/helloworld?lang=ja"
```

<details>
<summary>リクエスト2 - レスポンス</summary>

```sh
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 171
Date: Tue, 17 Dec 2024 04:44:01 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{
  "status": "success",
  "message": "こんにちは, admin!",
  "data": {
    "role": "admin",
    "permissions": ["read", "write", "admin"],
    "language": "ja"
  }
}
```

</details>

```sh
# リクエスト3
curl -i -H "Authorization: Bearer user-token" "http://mock:3030/sample/helloworld?lang=en"
```

<details>
<summary>リクエスト3 - レスポンス</summary>

```sh
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 141
Date: Tue, 17 Dec 2024 04:44:07 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{
  "status": "success",
  "message": "Hello, user!",
  "data": {
    "role": "user",
    "permissions": ["read"],
    "language": "en"
  }
}
```

</details>

```sh
# リクエスト4
curl -i -H "Authorization: Bearer user-token" "http://mock:3030/sample/helloworld?lang=ja"
```

<details>
<summary>リクエスト4 - レスポンス</summary>

```sh
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 151
Date: Tue, 17 Dec 2024 04:44:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{
  "status": "success",
  "message": "こんにちは, user!",
  "data": {
    "role": "user",
    "permissions": ["read"],
    "language": "ja"
  }
}
```

</details>

### 401 unauthorized

```sh
# リクエスト5
curl -i "http://mock:3030/sample/helloworld"
```

<details>
<summary>リクエスト5 - レスポンス</summary>

```sh
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Content-Length: 106
Date: Tue, 17 Dec 2024 04:44:18 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{
  "status": "error",
  "code": "UNAUTHORIZED",
  "message": "Hello... but who are you? Invalid token."
}
```

</details>

### 400 bad request

```sh
# リクエスト6
curl -i -H "Authorization: Bearer invalid-token" "http://mock:3030/sample/helloworld?lang=ja"
```

<details>
<summary>リクエスト6 - レスポンス</summary>

```sh
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Content-Length: 109
Date: Tue, 17 Dec 2024 04:44:22 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{
  "status": "error",
  "code": "INVALID_PARAMETERS",
  "message": "Invalid parameters or token provided."
}
```

</details>

## POST sample/helloworld

リクエストボディを利用したレスポンス例はこちらです。

### 200 success

```sh
# リクエスト7
curl -i -X POST -H "Content-Type: application/json" \
  -d '{"name":"John Doe"}' \
  "http://mock:3030/sample/helloworld"
```

<details>
<summary>リクエスト7 - レスポンス</summary>

```sh
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 64
Date: Tue, 17 Dec 2024 04:44:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{
  "status": "success",
  "message": "Hello, John Doeさん!"
}
```

</details>

### 400 bad request

```sh
# リクエスト8
curl -i -X POST -H "Content-Type: application/json" \
  -d '{}' \
  "http://mock:3030/sample/helloworld"
```

<details>
<summary>リクエスト8 - レスポンス</summary>

```sh
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Content-Length: 106
Date: Tue, 17 Dec 2024 04:44:36 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{
  "status": "error",
  "code": "BAD_REQUEST",
  "message": "Hello...名前を入力してください"
}
```

</details>

### 500 internal server error

```sh
# リクエスト9
curl -i -X POST -H "Content-Type: application/json" \
  -d '{"name":"error"}' \
  "http://mock:3030/sample/helloworld"
```

<details>
<summary>リクエスト9 - レスポンス</summary>

```sh
HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8
Content-Length: 116
Date: Tue, 17 Dec 2024 04:44:44 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{
  "status": "error",
  "code": "SYSTEM_ERROR",
  "message": "Hello...システムエラーが発生しました"
}
```

</details>

# Mockoon 実装方法解説

## JSON の構造

Mockoon の API 定義 JSON は以下の構造となっているようです。

```json
// mockoon.json (ファイル名は任意)
{
  "uuid": "api-identifier",      // API環境全体の一意識別子。意味のある名前を推奨（例:"user-api"）
  "name": "API Name",           // GUI上で表示される環境名。人が読みやすい名前を設定
  "endpointPrefix": "prefix",   // 全エンドポイントの共通プレフィックス（例:"api/v1"）
  "port": 3000,                // モックサーバのポート番号
  "routes": [                  // APIエンドポイントの配列
    {
      "uuid": "route-id",      // ルートの一意識別子。意味のある名前を推奨（例:"get-user-details"）
      "endpoint": "path",      // エンドポイントのパス（例:"/users/:id"）
      "method": "http-method", // HTTPメソッド（GET, POST, PUT, DELETE等）
      "responses": [...]       // ルートに対するレスポンスの配列
    }
  ]
}
```

## responses の構造

responses 配列の中身の構造は以下となっているようです。

```json
// "responses": [
  {
    "uuid": "response-id",        // レスポンスの一意識別子。意味のある名前を推奨（例:"admin-user-ja"）
    "label": "Response Label",    // GUI上で表示される説明的なラベル（例:"管理者向け日本語レスポンス"）
    "statusCode": 200,           // HTTPステータスコード
    "headers": [                 // レスポンスヘッダの配列
      {
        "key": "Content-Type",
        "value": "application/json"
      }
    ],
    "body": "...",              // レスポンスボディ
    "rules": [...],             // 条件分岐のルール（後述の rules の設定方法参照）
    "default": false            // デフォルトレスポンスかどうか
  },
  // ...
// ]
```

## responses の優先順位

Mockoon は responses 配列の先頭から順に評価し、最初にマッチしたレスポンスを返すという動きのようです。
そのため、以下の方針に沿って responses を定義するとよさそうです。

1.  より複合的、具体的な条件を responses 配列内で先に配置する
    - でないと一般的な条件が先にマッチしてしまい、具体的な条件が評価されないため
2.  デフォルトレスポンスは最後に配置する
    - `"default": true` を持つレスポンスは、他のどのルールにもマッチしない場合に使用される
    - デフォルトレスポンスを先に配置すると後続のルールが一切評価されなくなるため最後に配置する必要がある

## rules の設定方法

responses の rules 配列を調整することでレスポンスの条件分岐が可能です。

### 1. ヘッダによる分岐

```json
{
  "rules": [
    {
      "target": "header",
      "modifier": "Header-Name",
      "value": "Expected-Value",
      "operator": "equals"
    }
  ]
}
```

### 2. クエリパラメータによる分岐

```json
{
  "rules": [
    {
      "target": "query",
      "modifier": "paramName",
      "value": "expectedValue",
      "operator": "equals"
    }
  ]
}
```

### 3. リクエストボディによる分岐

```json
{
  "rules": [
    {
      "target": "body",
      "modifier": "paramName",
      "value": "expectedValue",
      "operator": "equals"
    }
  ]
}
```

### 4. 複数条件の組み合わせ

```json
{
  "rulesOperator": "AND",  // 複数ルールの結合方法（AND/OR）
  "rules": [...]
}
```

# まとめ

とても雑で恐縮ですが、本文のまとめです。

- Prism はシンプルなモック API を作るには良いが、柔軟なレスポンスが求められる場合には向いていない
- Mockoon なら柔軟なレスポンス出し分けが可能。ついでに GUI ツールも使えておいしい

# お詫びと言い訳

Mockoon のドキュメント読解作業は生成 AI の Claude3.5 Sonnet とのペアプロで進めました。ありがとう Claude 先生大変お世話になりました。
おかげで、1 日で実装することができたのですが、突貫で組み上げたため、本文の解説について意図せず誤った表現をしている部分のある可能性があります。
何かお気づきの点があればご指摘賜れますと幸いです 🙏