InsomniaのPluginを自作する

APIクライアントの[Insomnia](https://insomnia.rest/)は[プラグイン](https://docs.insomnia.rest/insomnia/introduction-to-plugins)によって機能拡張することが出来る。
出来ることは色々あるが例えば以下のような事が出来る。
- 見た目のカスタマイズ（テーマの追加）
- テスト用データの生成
- リクエスト発行時にヘッダの自動追加
- XMLからJSONへの変換

[Plugin Hub](https://insomnia.rest/plugins)に公開されているプラグインは自由にインストールして機能拡張出来るが、自作することも出来る。
試しに自作してみる。

## プラグインの基礎
プラグインはNode.jsで記述することが出来、最低2つのファイルから構成される。
- `package.json`：nodeモジュールのメタデータ
- `main.js`：JavaScriptのファイル

JavaScriptのファイル名は固定ではないので、別名にしても問題ない。
プラグインを含むフォルダを以下のディレクトリに展開することで利用可能となる。
- MacOS: `~/Library/Application\ Support/Insomnia/plugins/`
- Windows: `%APPDATA%\Insomnia\plugins\`
- Linux: `~/.config/Insomnia/plugins/`

PluginHubなどを使ってインストールした場合にはこのディレクトリにファイル・フォルダが展開される。
アンインストールする場合はこのディレクトリ内に展開されたディレクトリを削除する。

## プラグインの作成
Insomniaにプラグインの雛形を生成する機能があるので、これを使ってプラグインを作成してみる。
Insomniaの設定の`Plugins`の下の方にある`Generate New Plugin`をクリックすると雛形が生成される。
![20241129150715.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/30d95957-7929-dd17-276b-5fdb637e1202.png)

クリックするとプラグイン名を聞かれるので、デフォルトの`demo-example`のまま`Generate`をクリックする。
するとプラグイン一覧の中にたった今作成したプラグイン`insomnia-plugin-demo-example`が表示される。
※ Insomniaのプラグイン名は必ず先頭に`insomnia-plugin-`がつく
![20241129151931.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/c6103fe3-53cc-652f-bcea-87a089018a0f.png)


プラグイン名の横の`Reveal Folder`をクリックするとPluginのインストール先ディレクトリに移動出来るので、これをクリックして移動先のディレクトリでファイル編集してプラグインの実装を行っていく。
構成としては以下のようになっている。
```sh
insomnia-plugin-demo-example/
├── main.js
└── package.json
```
`package.json`には最小限のPluginの情報が記載されており、`main.js`はコメントのみで空になっている。
これをいじって色々試してみる。
なお、どういうものが呼び出したり操作したり出来るかは[Content Object Reference](https://docs.insomnia.rest/insomnia/context-object-reference)が参考になるが、詳細は書かれてないので実際に動かしたり既存のプラグインのコードを読んだほうが理解は進みそう。

### リクエスト送信時に処理を追加
最初に、リクエスト送信時にフックする機能を試してみる。
何かのイベントを発生させた際に処理を入れ込みたい時は[Hook](https://docs.insomnia.rest/insomnia/hooks-and-actions)を利用することで、リクエストとレスポンスに対してフックすることが出来る。
Insomniaのプラグインでは以下の2つを使うことでフックが利用できる。
```js
module.exports.requestHooks = Array<(context: RequestHook) => void>;
module.exports.responseHooks = Array<(context: ResponseHook) => void>;
```
ということで実際に書いてみる。
最初に、リクエスト送信時にHello Worldを画面出力してみる。
```js:main.js
module.exports = {
  requestHooks: [
    async (context) => {
      context.app.alert("Hello World");
    }
  ]
};
```
`requestHooks`でリクエスト送信時のフックを指定し、`context.app.alert`を呼ぶことでメッセージウィンドウが表示できる。
実際にCollectionからリクエストを送った結果がこちら。
![20241129172230.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/56044cbb-a930-9554-5d33-16d0cace2527.png)

意図したものが表示された。
少し工夫すれば任意のコマンドなんかも実行できる。
試しにpingを実行してみる。
```js:main.js
const { exec } = require('child_process');

module.exports = {
  requestHooks: [
    async (context) => {
      Command = 'ping localhost -c 1'
      exec(Command, (error, stdout, stderr) => {
        context.app.alert('Command Output', `Output:\n${stdout}`);
      });
    },
  ],
};
```
実行結果はこちらで、pingの結果が確認できる。
![20241129173021.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/44134fb7-ea05-1676-b4cb-6d5b19046418.png)

もう少し役立つケースとして、リクエストの内容をログ保存してみる。
```js
const fs = require('fs');

module.exports = {
  requestHooks: [
    async (context) => {
      const url = context.request.getUrl();
      const method = context.request.getMethod();
      const headers = context.request.getHeaders();
      const body = context.request.getBody();

      // ログ内容を作成
      const logContent = `
Request URL: ${url}
Method: ${method}
Headers: ${JSON.stringify(headers, null, 2)}
Body: ${body.text || '<No Body>'}
---
`;

      // ファイルに書き込む
      const logFilePath = '/tmp/request_log.txt';
      fs.appendFile(logFilePath, logContent, (err) => {
        if (err) {
          context.app.alert('Error', `Failed to write to file: ${err.message}`);
        }
      });
    },
  ],
};
```
プラグインをリロード後、GETとPOSTを実行した結果はこちら。
```sh
$ cat /tmp/request_log.txt

Request URL: http://httpbin.org/get
Method: GET
Headers: []
Body: <No Body>
---

Request URL: http://httpbin.org/post
Method: POST
Headers: [
  {
    "name": "Content-Type",
    "value": "application/json"
  }
]
Body: {
  "name": "John Doe",
  "email": "john.doe@example.com",
  "password": "secret123"
}
---
```
ログが残せていることが分かる。

### 操作方法そのものを追加
処理にフックするのではなく、処理そのもの（アクション）を足すことも出来る。
用意されているものは以下。
- `requestActions`：Collection内のリクエストにアクションを追加
- `requestGroupActions`：Collection内のリクエストのフォルダにアクションを追加
- `workspaceActions`：SpecとCollectionのドキュメント名にアクションを追加
- `documentActions`：ドキュメント（SpecとかCollectionとかの画面より前の画面）にアクションを追加

ここではフォルダ内のリクエスト全てを実行する[公式のサンプル](https://docs.insomnia.rest/insomnia/hooks-and-actions#example-plugin-to-send-all-requests-in-a-folder)を使って挙動を確認する。
公式サンプルのコードは以下になる。アイコンだけ変えてみた。
```js:main.js
module.exports.requestGroupActions = [
  {
    label: 'Send Requests',
    icon: 'fa-star',
    action: async (context, data) => {
      const { requests } = data;

      let results = [];
      for (const request of requests) {
        const response = await context.network.sendRequest(request);
        results.push(`<li>${request.name}: ${response.statusCode}</li>`);
      }

      const html = `<ul>${results.join('\n')}</ul>`;

      context.app.showGenericModalDialog('Results', { html });
    },
  },
];
```
`data`にフォルダ内のリクエスト一覧が入っており、for文内の`context.network.sendRequest`でリクエストを送信し、その結果を`リクエスト名：レスポンスコード`として`results`の中に格納している。
全リクエスト送信後、HTMLリスト形式でウィンドウに表示する、となっている。
実際に実行してみる。
追加されたアクションの場所が非常に分かりづらいが、フォルダの横の▼をクリックすると一番下にプラグイン名（今回はSend Requests）が表示されるので、これをクリックすると追加したアクションが実行できる。
![20241129182328.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/af32d40a-8692-932d-3f54-df5ea1ec2d07.png)
実行した結果はこちら。
![20241129182413.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/40c511ba-a684-0505-cb30-1c352c14978b.png)

実装した通りに表示された。

もう1つ試してみる。
API SpecのLintingは利用すると思うが、プロジェクト固有のルールを適用したい場合、独自のルールを組み込みたくなる。

以下、バージョンが1.0.0以上の場合にエラーとするカスタムプラグインの例となる。
<details><summary>main.js (クリックして表示)</summary><div>

```js:main.js
module.exports.workspaceActions = [{
  label: 'Validate API Spec Version',
  icon: 'fa-exclamation-triangle',
  action: async (context, models) => {
    // Insomniaのデータを取得
    const insomniaDataString = await context.data.export.insomnia({
      includePrivate: false,
      format: 'json',
      workspace: models.workspace,
    });

    let insomniaData;
    try {
      // insomniaDataをJSONにパース
      insomniaData = JSON.parse(insomniaDataString);
    } catch (err) {
      context.app.alert('Error', 'Failed to parse Insomnia Data: ' + err.message);
      return;
    }

    // 必要なモジュールをロード
    const yaml = require('js-yaml');
    const semver = require('semver');

    try {
      // API Specリソースをフィルタリング
      const apiSpecs = insomniaData.resources.filter(resource => resource._type === 'api_spec');

      // バージョンを検証
      for (const apiSpec of apiSpecs) {
        if (apiSpec.contents) {
          let specContent;
          try {
            // YAMLをパース
            specContent = yaml.load(apiSpec.contents);
          } catch (err) {
            context.app.alert('Error', `Failed to parse API Spec "${apiSpec.fileName}": ${err.message}`);
            return;
          }

          // バージョンを確認
          const version = specContent.info?.version;
          if (!version) {
            context.app.alert('Error', `API Spec "${apiSpec.fileName}" does not have a valid version.`);
            return;
          }

          // バージョンが1.0.0以上かチェック
          if (semver.gte(version, '1.0.0')) {
            context.app.alert(
              'Error',
              `API Spec "${apiSpec.fileName}" has an unsupported version (${version}). Version must be less than 1.0.0.`
            );
            return;
          }
        }
      }

      // 成功メッセージ
      context.app.alert('Success', 'All API Specs have valid versions.');
    } catch (err) {
      console.error('Error validating API Spec version:', err);
      context.app.alert('Error', `An error occurred: ${err.message}`);
    }
  },
}];
```
</div></details>

`js-yaml`モジュールと`semver`モジュールの取り込みのために必要に応じてインストールも行う。
```sh
npm install js-yaml
npm install semver
```
動作確認を行う。
以下のようなversionが`1.0.0`以上のAPI SpecをInsomnia上で作成する。
```yaml
openapi: 3.1.0
info:
  contact:
    name: hoge
  version: 1.1.0
：（以下省略）
```
バージョンが1.0.0以上なのでエラーが出るはずだ。
ファイル名の右の▼から作成したプラグインを指定する。
<img alt="picture 7" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/b8ed3d9f-0ce2-8a4f-8191-98cc024d7081.png" width=400 />

結果は以下のようになった。

![20241202085631.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/c5960470-6992-acaf-062c-154e6713133e.png)

問題が適切に検出できていることが分かる。
バージョンを0.0.1にして試してみる。
![20241202085751.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/5bde1e15-de4a-ee6c-6b45-f1f21e2c8e30.png)
今度はエラーが出なくなった。
ということで、API Specのチェックをするようなプラグインも作成できた。

## 所感
Insomniaのプラグインはまだベータ版らしく、ドキュメント等は揃っていない。
そのため、自作しようにも参考文献が少なくかなりハードルが高いように見える。
ただ、機能そのものはそこそこ揃っており、色んな箇所に任意の機能を差し込めるので、使い込めば使い込むほどカスタマイズの幅が広がりそうだ。
細かい機能が足りない場合は実装にチャレンジしてみるのも良いかも知れない。
あと、既にかなり多数のプラグインが公開されているので、それらも積極活用すると車輪の再発明も避けれてよいかと思う。
（プラグインのページが見づらいが。。。）

## おまけ：トラブルシュート
プラグイン作成後、`Tools`->`Reload Plugins`からプラグインをリロードすると、実装が適切ではない場合は以下のエラーが出る。
```sh
Failed to load plugin insomnia-plugin-demo-example. Please contact the plugin author sharing the below stack trace to help them to ensure compatibility with the latest Insomnia.
```

合わせてスタックトレースも確認できるので、ここから問題箇所を特定することが出来るはず。
```sh
/Users/hogehoge/Library/Application Support/Insomnia/plugins/insomnia-plugin-demo-example/main.js:3
    {
    ^

SyntaxError: Unexpected token '{'
    at makeContextifyScript (node:internal/vm:185:14)
    at wrapSafe (node:internal/modules/cjs/loader:1267:20)
    at Module._compile (node:internal/modules/cjs/loader:1328:27)
    at Module._extensions..js (node:internal/modules/cjs/loader:1432:10)
```

あと、Insomniaのデバッグモードも有効。
`View`->`Toggle DevTools`を選択するとブラウザの開発者モードのような画面が出てくる。
コード内に`console.log("message")`を入れておけば、この開発者モードの`Console`から確認できる。
所謂printfデバッグが出来るので、これでトライアンドエラーするのもあり。