はじめに
こちらは kintone Advent Calendar 2023 18日目の記事です。
kintone の API 連携 JavaScriptカスタマイズで使う kintone.proxy() ですが、これまでサンプルコードなどを参考に活用してきました。
cybozu developer network に 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 のブラウザの開発ツールでハックしてみました。
なお、実際の JavaScript カスタマイズで実行しているコードは以下です。以下の URL は Amazon S3 の静的Webサーバですが、kintone のドメインは CORS の設定で登録していません。
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 に渡されています。
{
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() のレスポンスになります。
{
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行) | |
取得ヘッダー | 文字列(複数行) | |
取得ファイル | 添付ファイル |
API の呼び出しにはヘッダーや、リクエストBODYに認証情報を含む場合が多く、アプリのアクセス権設定は必ず行いましょう!
kintone調査用アプリの仕組み
アプリのレコード追加画面、又は変更画面で kintone.proxy() で実行する API の URL を入力し、メソッドを選択して保管します。(画像はAPIの代わりに静的Webサイトを指定した場合の例)
レコード保管前にJavaScriptカスタマイズで指定されたURLに kintone.proxy() を実行、その結果をレコードに反映すると同時に、取得したデータをファイルとしてkintoneにアップロードします。詳細画面に戻った時点で以下のような結果を表示します。
kintone.proxy() のレスポンスは配列で、 「レスポンス配列要素数」 はその要素数です。
「配列要素のデータタイプ」 は配列の各要素のデータタイプ、サイズを表示しています。今回の例では配列データ要素 "0" のデータ型は string、string のサイズは528バイト、実データサイズは528バイトになります。htmlはテキストですから string のサイズは実データと同じになります。その他の項目はタイトル通りで、説明を省略します。
kintone調査用アプリのJavaScriptカスタマイズ
以下に JavaScript カスタマイズソースコードを公開します。急ぎ書いたコードでリファクタリング要素満載ですが、興味のある方は(自己責任で)ご自由にご活用ください。
JavaScript カスタマイズコード
(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();
}
})();
kintone.proxy() を調査した結果
アプリを作りながら kintone.proxy() について、バイナリーデータの取得と、実際に SORACOM API で一連処理を調査した結果についてまとめました。
取得したバイナリーデータの処理について
先に説明したように kintone.proxy() で外部APIから取得したデータは string 型で返ってきますので、json データならこれで問題なく処理できます。とはいえ、バイナリーデータも 10MB 以下なら string 型で取得できますので、JavaScript カスタマイズのみで kintone の貼付ファイルとして kintone.proxy() で取得したバイナリーデータを保管する方法がないか検討してみました。
kintone でファイルをアップロードする方法については cybozu developer network ファイルをアップロードする を確認ください。
先ず kintone の貼付ファイルに JavaScript でファイルをアップロードするためには、string のデータを blob データに変換する必要があります。以下のように string を一旦 ArrayBuffer に変換し blob データを作成、kintone にアップロード後ファイルキーを添付ファイルフィールドに設定して上書き更新しました。
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バイトの文字列に変換されていることがわかります。流石にデータが欠落してしまうと、対応方法がありません。
多くの API サービスは、画像などのバイナリーファイルを Base64 でエンコードして渡すのはこのためですね。
kintone.proxy() で string で取得したバイナリーデータでもファイルの種類を判別についても試してみましたが、こちらは以下のコードの処理で PNG、JPEG、PDF、XMLファイルを判別できました。通常はレスポンスヘッダーに Content-Type があるので、役立つかはさておきですが。(汗)
// 取得したデータの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日目の記事 の調査にも活用しました。
SORACOM API で、認証、各データの取得、設定の追加、削除、認証のログアウトまで、kintone.proxy() の一連の処理結果を確認できました。結果、kintone で「じぶんSORACOMコンソール」を作る事前調査に役立ちました。以下はsimグループを追加した例です。
まとめ
今回 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