kintone API の kintone.proxy() について調査アプリを作って詳しく調べてみた

# はじめに
こちらは [kintone Advent Calendar 2023](https://qiita.com/advent-calendar/2023/kintone) 18日目の記事です。

![kintoneProxy01a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/3107de69-3e39-6a19-082b-275f0b8de50d.png)

kintone の API 連携 JavaScriptカスタマイズで使う kintone.proxy() ですが、これまでサンプルコードなどを参考に活用してきました。

[cybozu developer network に kintone.proxy() の詳細なドキュメント](https://cybozu.dev/ja/kintone/docs/js-api/proxy/kintone-proxy/)はありますが、実際に外部APIを呼び出したレスポンスのヘッダーやデータなどの例が少なく、いつもその解析のために console.log() で出力してブラウザの開発ツールで都度確認するなど面倒でした。

そこで、 **kintone.proxy() で外部APIを試験・分析する kintone アプリを作り、kintone.proxy() の詳細について調査** してみました。

# kintone.proxy() について
kintone.proxy() について少し解説します。

## kintone.api() や fetch() との違い
kintone.proxy() 以外でも、kintone.api() や fetch() で外部URLからAPIを呼び出すことができます。

しかしながら、kintone.api() や fetch() ではドメインの違う外部APIは CORS の設定で kintone のドメインが登録されてないと、ブラウザ側でエラーとなり、外部APIを呼び出すことができません。当然ながら、外部API提供側で kintone のドメインを CORS の設定で登録していませんので、ブラウザ側でエラーになります。

CORSについて詳細を知りたい方は以下を参照ください。
**オリジン間リソース共有 (CORS)**
https://developer.mozilla.org/ja/docs/Web/HTTP/CORS

API提供側で CORS の設定がなくても、kintoneカスタマイズの JavaScript で利用可能にしてくれたのが kintone.proxy() です。

# kintone.proxy() の動作をブラウザ開発ツールでハック

先ずはどのような挙動をするのか kintone.proxy() の通信を Chrome のブラウザの開発ツールでハックしてみました。

![kintoneProxy01b.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/5f29b675-67d7-9f07-9c02-3a3e11a01c1f.png)

なお、実際の JavaScript カスタマイズで実行しているコードは以下です。以下の URL は Amazon S3 の静的Webサーバですが、kintone のドメインは CORS の設定で登録していません。

```javascript
const url = "https://kintes.s3.ap-northeast-3.amazonaws.com/index.html";
const method = "GET";
const headers = {};
const body = {};
kintone.proxy(url, medhod, headers, body);
```

この kintone.proxy() 実行時にブラウザから発する POST リクエストURL（そのままでは分かりづらいのでURLデコードしています）が以下であることがわかります。

```
https://<ホスト名>.cybozu.com/k/api/proxy/call.json?_lc=ja&_ref=https://<ホスト名>.cybozu.com/k/355/show#record=1&l.view=6448362&l.q&l.sort_0=f6448332&l.order_0=DESC&l.next=0&l.prev=2&mode=edit
```

**kintone.proxy() の実態が /k/api/proxy/call.json** であることが分かります。call.json のペイロード（リクエストBODY）を確認すると、kintone.proxy() の引数がJSONで call.json に渡されています。

```json
{
    url: "https://kintes.s3.ap-northeast-3.amazonaws.com/index.html",
    body:{},
    headers:{},
    method:"GET",
    __REQUEST_TOKEN__:"429fc589-78d9-4296-bb83-80297f2ccd97"
}
```

call.json は引数の url と header、body、method を受け取り、CORS の影響を受けないサーバサイド処理（Node.js？と推察）で API を呼び出して、そのレスポンスを返しているように見えます。

call.json からのレスポンス（JSON）を確認すると、result に body と status、headers が含まれ、これがそのまま kintone.proxy() のレスポンスになります。

```json
{
    result:{
        body: "<!doctype html>（中略）</html>",
        status: 200,
        headers:{
            Accept-Ranges: "bytes", 
            （中略）
            x-amz-server-side-encryption:"AES256"
        }
    },
    success:true
}
```

シンプルにAPIを中継、レスポンスを返しているようです。

## kintone.proxy() の謎

kintone.proxy() で JSON を取得する場合は何ら問題はありませんが、ドキュメントにには **「実行する外部 API のレスポンスボディは、文字列のみ対応しています。画像などのバイナリーデータは取得できません。」** とあります。

しかしながら、**kintone.proxy() で画像を取得しても処理は正常終了** します。ただ、 **取得データをバイナリーとして扱う blob 変換ができなくて、レスポンスのデータをそのまま kintone の貼付ファイルに保管するこは断念** しましたが、その辺りについては後で詳しく解説したいと思います。

# kintone.proxy() 調査用アプリ
今回 kintone.proxy() 調査するために、API の URL やヘッダー、リクエストBODYを入力し、保管後の submit 後のタイミングで kintone.proxy() を実行し、その結果を詳しくレコードに保管する kintone アプリを作成しました。

## kintone調査用アプリの構成
アプリのフィールド構成は以下です。

| フィールドコード | フィールドタイプ | 解説 |
|:-----------|:------------|:------------|
| 試験項目 | 文字列（1行） |  |
| APIを実行 | ラジオボタン | 実行する, 実行しない |
| URL | 文字列（1行） | 必須入力 |
| メソッド | ラジオボタン | GET, POST, PUT, DELETE |
| ヘッダー | 文字列（1行） | JSON形式で入力 |
| ボディ | 文字列（1行） | JSON形式で入力 |
| デコードURL | 文字列（1行） | 「URL」のURLデコード値 |
| レスポンス配列要素数 | 数値 |  |
| 取得データ | 文字列（複数行） |  |
| 取得データMIME | 文字列（1行） |  |
| 取得ステータス | 文字列（1行） |  |
| ファイルキー | 文字列（1行） |  |
| 取得ヘッダー | 文字列（複数行） |  |
| 取得ファイル | 添付ファイル |  |

:::note alert
API の呼び出しにはヘッダーや、リクエストBODYに認証情報を含む場合が多く、アプリのアクセス権設定は必ず行いましょう！
:::

## kintone調査用アプリの仕組み

アプリのレコード追加画面、又は変更画面で kintone.proxy() で実行する API の URL を入力し、メソッドを選択して保管します。（画像はAPIの代わりに静的Webサイトを指定した場合の例）

![kintoneProxy02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/697b9c36-b615-0355-9ba2-70889c5f121f.png)

レコード保管前にJavaScriptカスタマイズで指定されたURLに kintone.proxy() を実行、その結果をレコードに反映すると同時に、取得したデータをファイルとしてkintoneにアップロードします。詳細画面に戻った時点で以下のような結果を表示します。

![kintoneProxy03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/05d9d974-d39b-079f-1828-353dd08883c1.png)

kintone.proxy() のレスポンスは配列で、 **「レスポンス配列要素数」** はその要素数です。

**「配列要素のデータタイプ」** は配列の各要素のデータタイプ、サイズを表示しています。今回の例では配列データ要素 "0" のデータ型は string、string のサイズは528バイト、実データサイズは528バイトになります。htmlはテキストですから string のサイズは実データと同じになります。その他の項目はタイトル通りで、説明を省略します。

## kintone調査用アプリのJavaScriptカスタマイズ

以下に JavaScript カスタマイズソースコードを公開します。急ぎ書いたコードでリファクタリング要素満載ですが、興味のある方は（自己責任で）ご自由にご活用ください。

<details><summary>JavaScript カスタマイズコード</summary>

```kintoneProxyTest.js
(function() {
	"use strict";

    // SUBMITイベント
    const SubmitEvent = ["app.record.create.submit", "app.record.edit.submit"];
	kintone.events.on(SubmitEvent, async function(event) {

        if(event.record.APIを実行.value == "実行しない"){
            return event;
        }

        // kintone.proxy を実行
        const proxy = await ExecuteKintoneProxy(event);

        // 取得したデータのMIMEを解析しファイル名を設定
        const mime = await AnalyzeData(proxy[0]);

        // 取得したデータをアップロード
        const file = await UploadFile(proxy[0], event.record.URL.value, mime);

        // レコードに取得したデータを反映
        event.record.レスポンス配列要素数.value = proxy.length;
        event.record.配列要素のデータタイプ.value = "0:" + (typeof proxy[0]) + "(size:" + proxy[0].length + ", actual-size:" + file.fileSize + ")"
            + "  1:" + (typeof proxy[1]) + "(size:" + String(proxy[1]).length  + ")"
            + "  2:" + (typeof proxy[2]) + "(size:" + String(proxy[2]).length + ")";
        if(proxy[0].substring(0, 1) == "{"){
            event.record.取得データ.value = JSON.stringify(JSON.parse(proxy[0]), null, 2).substring(0, 5000);
        }else{
            event.record.取得データ.value = proxy[0].substring(0, 5000);
        }
        event.record.取得データMIME.value = mime.type;
        event.record.取得ステータス.value = proxy[1];
        event.record.ファイルキー.value = file.fileKey;
        event.record.取得ヘッダー.value = JSON.stringify(proxy[2], null, 2);
        event.record.デコードURL.value = decodeURI(event.record.URL.value);

		return event;
	});

    // SUBMIT後イベント
    const SuccessEvent = ["app.record.create.submit.success", "app.record.edit.submit.success"];
	kintone.events.on(SuccessEvent, async function(event) {
        
        if(event.record.APIを実行.value == "実行しない" || event.record.ファイルキー.value == ""){
            return event;
        }

        // ファイルキーの更新
        await UpdateFileKey(event.record.$id.value, event.record.ファイルキー.value);

		return event;
	});

    // kintone.proxy を実行
    async function ExecuteKintoneProxy(event){

        const url = event.record.URL.value;
        const medhod = event.record.メソッド.value;
        let headers;
        if(!event.record.ヘッダー.value){
            headers = {};
        }else{
            headers = JSON.parse(event.record.ヘッダー.value.toString());
        }
        let body;
        if(medhod == "GET" || !event.record.ボディ.value){
            body = {};
        }else{
            body = JSON.parse(event.record.ボディ.value.toString());
        }

        return await kintone.proxy(url, medhod, headers, body);
    }

    // 取得したデータのMIMEを解析しファイル名を設定
    async function AnalyzeData(data){

        let response = { type : "", fileName : "" }
        const jpegStr = (new TextDecoder).decode(Uint8Array.of(0xff, 0xd8));

        if(await data.substring(0, 10).indexOf(jpegStr) != -1){
            response.type = "image/jpeg"; 
            response.fileName = "image.jpg";
        }else if(await data.substring(0, 10).indexOf("PNG") != -1){
            response.type = "image/png";
            response.fileName = "image.png";
        }else if(await data.substring(0, 10).indexOf("GIF") != -1){
            response.type = "image/gif";
            response.fileName = "image.gif";
        }else if(await data.substring(0, 500).indexOf("xml") != -1){
            response.type = "text/xml";
            response.fileName = "document.xml";
        }else if(await data.substring(0, 500).indexOf("PDF") != -1){
            response.type = "application/pdf";
            response.fileName = "document.pdf";
        }else{
            response.type = "text/plain";
            response.fileName = "document.txt";
        }
        return response;
    }

    // 取得したデータをファイルでアップロード
    async function UploadFile(data, url, mime){

        let response = { fileKey : "", fileSize : 0 }

        let blob;
        if(mime.type == "text/plain"){
            blob = new Blob([data], { type: mime.type });
        }else{
            // バイナリーデータを再取得（kintone.proxy() は取得したバイナリを文字列に変換するため）
            blob = await GetBlobForUrlFile(url);
        }

        const formData = new FormData();
        formData.append('__REQUEST_TOKEN__', kintone.getRequestToken());
        formData.append('file', blob, mime.fileName);
        const formHeaders = {
            'X-Requested-With': 'XMLHttpRequest'
        };

        // kintone.api() でフォームデータが送信できないのでfetch()を使う
        const upFile = await fetch('/k/v1/file.json', {
            method: 'POST',
            headers: formHeaders,
            body: formData,
        });
        const upFileData = await upFile.json();
        
        response.fileKey = upFileData.fileKey;
        response.fileSize = blob.size;
        return response;
    }

    // ファイルキーの更新
    async function UpdateFileKey(id, fileKey){
        const body = {
            app: kintone.app.getId(),
            id: id,
            record: {
                取得ファイル: {
                    value: [ { fileKey : fileKey } ]
                }
            }
        };
        await kintone.api(kintone.api.url('/k/v1/record.json', true), 'PUT', body);
    }

    // CORS対策した自作WebAPIを利用してBlobデータを取得（非公開）
    async function GetBlobForUrlFile(url){
        const apiUrl = "自作 WebAPI URL は都合上非公開でごめんなさい";
        const response = await fetch(apiUrl+encodeURIComponent(url));
        return await response.blob();
    }
})();
```

</details>

# kintone.proxy() を調査した結果
アプリを作りながら kintone.proxy() について、バイナリーデータの取得と、実際に SORACOM API で一連処理を調査した結果についてまとめました。

## 取得したバイナリーデータの処理について
先に説明したように kintone.proxy() で外部APIから取得したデータは string 型で返ってきますので、json データならこれで問題なく処理できます。とはいえ、バイナリーデータも 10MB 以下なら string 型で取得できますので、JavaScript カスタマイズのみで kintone の貼付ファイルとして kintone.proxy() で取得したバイナリーデータを保管する方法がないか検討してみました。

kintone でファイルをアップロードする方法については [cybozu developer network ファイルをアップロードする](https://cybozu.dev/ja/kintone/docs/rest-api/files/upload-file/) を確認ください。

先ず kintone の貼付ファイルに JavaScript でファイルをアップロードするためには、string のデータを blob データに変換する必要があります。以下のように string を一旦 ArrayBuffer に変換し blob データを作成、kintone にアップロード後ファイルキーを添付ファイルフィールドに設定して上書き更新しました。

```javascript
const proxy = await kintone.proxy(url, medhod, headers, body);
const buffer = ConvertStringToArrayBuffer(proxy[0]);
blob = new Blob([buffer.buffer], { type: type });

// 文字列をArrayBufferに変換
function ConvertStringToArrayBuffer(str) {
    const buffer = new ArrayBuffer(str.length);
    const bufferView = new Uint8Array(buffer);
    for (let i = 0; i < str.length; i++) {
        bufferView[i] = str.charCodeAt(i);
    }
    return buffer;
}
```

**結果、添付ファイルの保管には成功しましたが、画像やPDFなどのファイルが壊れていました。**
今更当たり前の話なんですが、バイナリーには文字には利用されない制御コード等も含まれるため、string 変換時にその辺りのデータがカットさます。以下の画像のように png ファイルで試した結果は、485,951バイトの画像が、409,518バイトの文字列に変換されていることがわかります。流石にデータが欠落してしまうと、対応方法がありません。

![kintoneProxy04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/b0703857-39ff-3e27-fc7e-af0002e41564.png)

多くの API サービスは、画像などのバイナリーファイルを Base64 でエンコードして渡すのはこのためですね。

kintone.proxy() で string で取得したバイナリーデータでもファイルの種類を判別についても試してみましたが、こちらは以下のコードの処理で PNG、JPEG、PDF、XMLファイルを判別できました。通常はレスポンスヘッダーに Content-Type があるので、役立つかはさておきですが。（汗）

```javascript
    // 取得したデータのMIMEを解析しファイル名を設定
    async function AnalyzeData(data){

        let response = { type : "", fileName : "" }
        const jpegStr = (new TextDecoder).decode(Uint8Array.of(0xff, 0xd8));

        if(await data.substring(0, 10).indexOf(jpegStr) != -1){
            response.type = "image/jpeg"; 
            response.fileName = "image.jpg";
        }else if(await data.substring(0, 10).indexOf("PNG") != -1){
            response.type = "image/png";
            response.fileName = "image.png";
        }else if(await data.substring(0, 10).indexOf("GIF") != -1){
            response.type = "image/gif";
            response.fileName = "image.gif";
        }else if(await data.substring(0, 500).indexOf("xml") != -1){
            response.type = "text/xml";
            response.fileName = "document.xml";
        }else if(await data.substring(0, 500).indexOf("PDF") != -1){
            response.type = "application/pdf";
            response.fileName = "document.pdf";
        }else{
            response.type = "text/plain";
            response.fileName = "document.txt";
        }
        return response;
    }
```

## SORACOM APIでの試験

今回の調査アプリは SORACOM Advent Calendar 2023 16日目の記事 の調査にも活用しました。

![kintoneProxy06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/6e65d810-46ce-63b5-f153-7fd2ad7bd86b.png)

SORACOM API で、認証、各データの取得、設定の追加、削除、認証のログアウトまで、kintone.proxy() の一連の処理結果を確認できました。結果、kintone で「じぶんSORACOMコンソール」を作る事前調査に役立ちました。以下はsimグループを追加した例です。

![kintoneProxy07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/5d27532e-ad03-8135-65da-d5d8ab3e0e26.png)

# まとめ
今回 kintone.proxy() を詳しく調べた結果は以下です。

**・kintone.proxy() は取得文字列データ、ステータスコード、レスポンスヘッダーを確認可能**
**・kintone.proxy() でバイナリーデータを取得しても、仕様通り文字列（string）を返す**
**・取得した上記文字列はデータが欠落し、バイナリー（blob）データとして扱えない**
**・取得した上記文字列からデータ種類の推測は可能（PNG、JPEG、PDF、XML で確認）**

もし kintone.proxy() がなかったら、CORS の問題で kintone から外部APIの活用は厳しいと推察され、そういう意味では大変評価できる JavaScript API と言えます！

ただ、**バイナリーファイルが扱えないのが残念** で、せめて画像やPDFは扱いたいものです。無論負荷をかけ過ぎないようにレスポンスのボディ上限10MBの範囲で良いので、 **今後のアップデートで blob か ArrayBuffer データを取得できるようになることを切に願っています。** 

# 参考情報
**cybozu developer network 外部の API を実行する**
https://cybozu.dev/ja/kintone/docs/js-api/proxy/kintone-proxy/
**cybozu developer network ファイルをアップロードする**
https://cybozu.dev/ja/kintone/docs/rest-api/files/upload-file/
**Serial Devices | Apps - Chrome for Developers**
https://developer.chrome.com/docs/apps/app_serial?hl=ja
**SORACOM API リファレンス**
https://users.soracom.io/ja-jp/tools/api/reference/
**ArrayBuffer**
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer