概要
この記事では、Google Apps Script (GAS)でホスティングしたウェブアプリからBox上のファイルをメタデータで検索する方法を紹介します。
具体的なケースとしては、Boxで保存されているファイルに対し、外部のユーザーがBoxアカウントを持っていなくてもアクセス(検索・プレビュー・ダウンロード)できるよう提供したい場合です。
Boxは、メタデータ機能によって、Box内のファイルに関する追加情報を定義することができます。
その情報を用いて、より細かい条件でファイルを検索・分類することが可能になります。
Boxウェブ版はもちろん、Box API経由でもメタデータクエリでファイルを検索することが可能ですので、外部のアプリケーションからBox内のファイルを検索することができます。そのようなアプリケーションの例として、この記事ではGASを利用し、Boxメタデータで検索するGAS上のウェブアプリケーションを作ります。
そして、GASとBox UI Elementsの連携の追加例として、検索結果のファイルをプレビューできるようにBox UI ElementsをGASのウェブアプリに埋め込みます。
以下は手順です:
- ユーザーはGASでホスティングされているウェブアプリにアクセスし、検索したい値をフォームに入力します。
- ウェブアプリはユーザーの検索条件を解析し、メタデータクエリリクエストをBox APIに送信します。
- Boxはクエリ結果(ファイル情報)をレスポンスとして返却します。
- ウェブアプリはクエリ結果から必要な情報だけ抽出し、表示します。
- ユーザーはウェブアプリ内のBox UI Elements を通じて直接Box上のファイルを閲覧やダウンロードが行えます。
この記事では、なるべくわかりやすくするため、コードを極力省いています。
セキュリティ面での行うべき考慮を省略し、エラーハンドリングなども実装されていません。
具体的な実装時には非機能面の考慮と実装方法を開発者が行う必要があります。
準備
BoxとGoogle Apps Script (GAS)との必要な準備をここで説明します。
Box
カスタムアプリ作成
オフィシャル設定ガイドに従って、Boxのカスタムアプリを作ることができますが、この記事で使用される設定を説明します。
まずは、開発者コンソールでサーバ認証(クライアント資格情報許可)を利用するカスタムアプリを作成します。
カスタムアプリがアクセス可能なフォルダ内のみ検索するため、特別な権限は不要です。アプリの「構成」タブの「アプリアクセスレベル」、「アプリケーションスコープ」と「高度な機能」セクションでは特に変更はありません。
GASのウェブアプリ上からBox UI Elementsを利用するため、「CORSドメイン」セクションでGASが利用するドメイン(https://*.googleusercontent.com
)の追加が必要です。
CORSドメインを指定し、上左にある「変更を保存」ボタンで設定を保存します。
次は、「承認」タブで「確認して送信」ボタンを押下し、Box管理者へアプリの承認依頼が送信されます。承認が完了したら、自動的にそのカスタムアプリのサービスアカウントが生成され、「一般設定」タブでそのサービスアカウントのユーザーメールアドレスが表示されます。
最後に、「構成」タブの「OAuth 2.0資格情報」から、Webアプリケーションで使用するためのクライアントIDとクライアントシークレットを取得できます。
メタデータテンプレート作成
ファイルにカスタムメタデータを追加する前に、Box管理コンソールのメタデータテンプレートの「コンテンツ」パネルの「メタデータ」タブで作成します。
この記事では以下のようなテンプレートが使用されています:
- テンプレート名:Contracts
- テンプレート属性:Client(テキスト)、Contract Start(日付)、Contract End(日付)、Value(数字)
Boxウェブ版では、メタデータテンプレート名と属性名に日本語を使用することができます。ただし、自動的にローマ字の名前が生成されます。生成された名前を確認するには、Box APIを使用してメタデータテンプレートの詳細情報を取得する必要があります:https://ja.developer.box.com/reference/get-metadata-templates-enterprise/
テスト用のフォルダを作成し、契約書や請求書などのテストファイルをアップロードします。次に、各ファイルに対応したメタデータテンプレートを選択し、メタデータを追加します。
最後に、テスト用のフォルダのコラボレーターとして、カスタムアプリのサービスアカウントを招待します。これによりサービスアカウントはそのフォルダへアクセスできるようになり、フォルダ内に検索機能も利用可能です。
Google Apps Script (GAS)
まず、新しいGASプロジェクトを作成します。
Boxと接続するために、スクリプトエディター画面の左側にある「ライブラリ」セクションで「OAuth2 For Apps Script」ライブラリ(ID:1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF
)を追加します。
実装
アプリのコードを以下のように書きます。最初にある設定項目を編集するが必要です:
-
clientId
はBox上でカスタムアプリを作成した際に生成されたクライアントIDです。 -
clientSecret
はBox上でカスタムアプリを作成した際に生成されたクライアントシークレットです。 -
enterpriseId
はBoxテナントのエンタープライズIDで、管理コンソールの「アカウントと請求」パネルに記載されています。 -
ancestorFolderId
は準備のステップで作成されたフォルダIDです。このIDはフォルダをアクセスし、そのページURL末尾から取得可能です。 -
metadataScope
はグローバルかエンタープライズで、この記事ではエンタープライズスコープを使います。そのスコープはenterprise_
と[エンタープライズID]
で定義されます。 -
metadataTemplateKey
はBox側でテンプレートを作成された際に生成され、メタデータテンプレートの編集画面のURLから取得できま。例えば、URLはhttps://company.app.box.com/master/metadata/templates/contracts
場合は、テンプレートキーはcontracts
です。
// アプリで利用する設定項目
const settings = {
// Box上のカスタムアプリのクライアントID
clientId: "f666d88baea50d0c4169188c64b70199",
// Box上のカスタムアプリのクライアントシークレット
clientSecret: "c79c878c0bbfd1df7b90a37a3a4198bc",
// BoxのエンタープライズID
enterpriseId: "12345678",
// 検索するフォルダ(サブフォルダも検索対象になる)
ancestorFolderId: "987654321",
// メタデータスコープ
metadataScope: "enterprise_12345678",
// メタデータテンプレートキー
metadataTemplateKey: "contracts",
// アクセストークンを取得と制限する際に利用するURL
requestTokenUrl: "https://api.box.com/oauth2/token",
// アプリで扱う日付に関する項目
timezone: "UTC+9",
frontDateFormat: "yyyy年M月d日",
backDateFormat: "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
};
// 「FileSearch.html」というファイルで定義されている検索ページを提供する
// 参照:https://developers.google.com/apps-script/guides/web?hl=ja
function doGet() {
return HtmlService.createTemplateFromFile("FileSearch")
.evaluate()
.setTitle("GASとBoxの連携デモ");
}
// OAuth2ライブラリを利用し、Box APIへリクエストで利用されるサービスを初期化する
// 参照:https://github.com/googleworkspace/apps-script-oauth2#using-alternative-grant-types
function getBoxService() {
return OAuth2.createService("box")
.setTokenUrl(settings.requestTokenUrl)
.setParam("client_id", settings.clientId)
.setParam("client_secret", settings.clientSecret)
.setParam("box_subject_type", "enterprise")
.setParam("box_subject_id", settings.enterpriseId)
.setGrantType("client_credentials")
.setPropertyStore(PropertiesService.getScriptProperties());
}
// Box UI Elementsを利用する際に、クライアント(ユーザーのブラウザー)でアクセストークンを保存するが、
// ファイルプレビューのみ対応するトークンに変更し、ダウンスコープ(制限)されたトークンをクライアントに送信する。
// 参照:https://ja.developer.box.com/guides/authentication/tokens/downscope/#downscoping-in-practice
function getDownscopedToken() {
const boxService = getBoxService();
const config = {
method: "POST",
payload: {
subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
scope: "base_preview",
subject_token: boxService.getAccessToken(),
},
contentType: "application/x-www-form-urlencoded",
};
const response = JSON.parse(
UrlFetchApp.fetch(settings.requestTokenUrl, config).getContentText()
);
return response.access_token;
}
// メタデータ検索リクエストを作成し、送信する
// 参照:https://ja.developer.box.com/reference/post-metadata-queries-execute-read/
function searchBoxFiles(searchConditions) {
const boxService = getBoxService();
const { queryParameters, validConditions } =
parseSearchConditions(searchConditions);
const config = {
payload: JSON.stringify({
from: `${settings.metadataScope}.${settings.metadataTemplateKey}`,
query: validConditions.join(" AND "),
query_params: queryParameters,
fields: [
"id",
"name",
`metadata.${settings.metadataScope}.${settings.metadataTemplateKey}.client`,
`metadata.${settings.metadataScope}.${settings.metadataTemplateKey}.value`,
`metadata.${settings.metadataScope}.${settings.metadataTemplateKey}.contractStart`,
`metadata.${settings.metadataScope}.${settings.metadataTemplateKey}.contractEnd`,
],
ancestor_folder_id: settings.ancestorFolderId,
}),
method: "POST",
contentType: "application/json",
headers: {
Authorization: "Bearer " + boxService.getAccessToken(),
},
};
const response = JSON.parse(
UrlFetchApp.fetch(
"https://api.box.com/2.0/metadata_queries/execute_read",
config
).getContentText()
);
const files = parseMetadataResponse(response);
return files;
}
// メタデータ検索結果をパースし、ファイル情報を取得する
function parseMetadataResponse(response) {
const files = [];
for (const entry of response.entries) {
files.push({
id: entry.id,
name: entry.name,
client:
entry.metadata[settings.metadataScope][settings.metadataTemplateKey]
.client,
value:
entry.metadata[settings.metadataScope][settings.metadataTemplateKey]
.value,
contractStart: convertDate(
entry.metadata[settings.metadataScope][settings.metadataTemplateKey]
.contractStart,
settings.backDateFormat,
settings.frontDateFormat
),
contractEnd: convertDate(
entry.metadata[settings.metadataScope][settings.metadataTemplateKey]
.contractEnd,
settings.backDateFormat,
settings.frontDateFormat
),
});
}
return files;
}
// ユーザーが入力した条件をパースし、メタデータクエリに必要な項目を作成する
// 参照:https://ja.developer.box.com/guides/metadata/queries/syntax/
function parseSearchConditions(searchConditions) {
// 有効な検索条件
const validConditions = [];
// 検索条件に利用されているパラメーターの値
const queryParameters = {};
if (searchConditions.client) {
validConditions.push("client ILIKE :client");
queryParameters.client = `%${searchConditions.client}%`;
}
if (searchConditions.valueStart) {
validConditions.push("value >= :valueStart");
queryParameters.valueStart = searchConditions.valueStart;
}
if (searchConditions.valueEnd) {
validConditions.push("value <= :valueEnd");
queryParameters.valueEnd = searchConditions.valueEnd;
}
if (searchConditions.contractStart) {
validConditions.push("contractStart >= :contractStart");
queryParameters.contractStart = convertDate(
searchConditions.contractStart,
settings.frontDateFormat,
settings.backDateFormat
);
}
if (searchConditions.contractEnd) {
validConditions.push("contractEnd <= :contractEnd");
queryParameters.contractEnd = convertDate(
searchConditions.contractEnd,
settings.frontDateFormat,
settings.backDateFormat
);
}
return {
queryParameters,
validConditions,
};
}
// 日付を変換する
// 参照:https://developers.google.com/apps-script/reference/utilities/utilities?hl=ja#parsedatedate,-timezone,-format
// 参照:https://developers.google.com/apps-script/reference/utilities/utilities?hl=ja#formatdatedate,-timezone,-format
function convertDate(date, fromFormat, toFormat) {
return Utilities.formatDate(
Utilities.parseDate(date, settings.timezone, fromFormat),
settings.timezone,
toFormat
);
}
次に、スクリプトエディターの左側パネルから「FileSearch」という新しいHTMLファイルを作成し、以下のコードを書きます。
<!DOCTYPE html>
<html>
<head>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css"
/>
<link
rel="stylesheet"
href="https://cdn01.boxcdn.net/platform/preview/2.93.0/en-US/preview.css"
/>
</head>
<body>
<div id="container" class="container">
<div id="section" class="section center-align">
<h2>メタデータでファイル検索</h2>
<h5>Google Apps Script(GAS)上のウェブアプリとBoxの連携デモ</h5>
</div>
<div class="row">
<form
id="form"
onsubmit="event.preventDefault(); submitForm(this)"
class="col s12"
>
<div class="row">
<div class="input-field col s12">
<input id="client" type="text" />
<label for="client">契約先</label>
</div>
</div>
<div class="row">
<div class="input-field col s6">
<input id="valueStart" type="text" />
<label for="valueEnd">契約金額-下限(万円)</label>
</div>
<div class="input-field col s6">
<input id="valueEnd" type="text" />
<label for="valueEnd">契約金額-上限(万円)</label>
</div>
</div>
<div class="row">
<div class="input-field col s6">
<label for="contractStart">契約開始日</label>
<input
type="text"
class="datepicker"
id="contractStart"
name="contractStart"
/>
</div>
<div class="input-field col s6">
<label for="contractEnd">契約開始日</label>
<input
type="text"
class="datepicker"
id="contractEnd"
name="contractEnd"
/>
</div>
</div>
<div class="row">
<div class="center-align">
<button id="search" class="btn" type="submit">検索</button>
</div>
</div>
</form>
</div>
<div class="row">
<table id="resultsTable" class="striped hide">
<thead>
<tr>
<th>ファイル名</th>
<th>契約先</th>
<th>契約金額(万円)</th>
<th>契約開始日</th>
<th>契約終了日</th>
<th>プレビュー</th>
</tr>
</thead>
<tbody id="resultsTableBody"></tbody>
</table>
<div id="filePreviewModal" class="modal">
<div class="modal-content">
<h4 id="modalFileName">ファイル名</h4>
<div
class="preview-container"
style="height: 400px; width: 100%"
></div>
</div>
<div class="modal-footer">
<a href="#!" class="modal-close waves-effect waves-green btn-flat"
>閉じる</a
>
</div>
</div>
</div>
<div id="statusMessage"></div>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<script src="https://cdn01.boxcdn.net/platform/preview/2.93.0/ja-JP/preview.js"></script>
<script>
let downscopedToken = "";
$(document).ready(function () {
// Box UI Elementsのプレビュー要素を初期化する
const preview = new Box.Preview();
// Materialize CSSの要素(フォーム項目)を初期化する
M.AutoInit();
$("select").formSelect();
$(".datepicker").datepicker({
format: "yyyy年m月d日",
});
$(".modal").modal({
onOpenStart: function (modal, trigger) {
const fileId = $(trigger).data("fileid");
const fileName = $(trigger).data("filename");
$("#modalFileName").text(fileName);
preview.show(fileId, downscopedToken, {
container: ".preview-container",
showDownload: true,
});
},
onCloseEnd: function () {
preview.hide();
},
});
// ページロードが完了したら、Box UI Elementsで利用する制限されたトークンを取得する
google.script.run
.withSuccessHandler(onSuccessGetDownscopedToken)
.withFailureHandler(onFailure)
.getDownscopedToken();
});
// GASサーバから来るトークンを変数に保存する
function onSuccessGetDownscopedToken(token) {
downscopedToken = token;
}
function submitForm(form) {
// 処理中のUI要素を表示する
startProcessing();
// 検索フォームの値を取得し、GASサーバ上の検索関数に渡す
const searchConditions = {
client: $("#client").val(),
contractStart: $("#contractStart").val(),
contractEnd: $("#contractEnd").val(),
valueStart: $("#valueStart").val(),
valueEnd: $("#valueEnd").val(),
};
google.script.run
.withSuccessHandler(onSucessSearchBoxFiles)
.withFailureHandler(onFailure)
.searchBoxFiles(searchConditions);
}
// GASサーバから来る検索結果のファイル情報を結果表に追加する
function onSucessSearchBoxFiles(files) {
finishProcessing();
if (files.length == 0) {
$("#statusMessage").append(
`
<div class="card-panel red darken-2">
<span class="white-text">
<strong>${error.message}</strong>
</span>
</div>
`
);
$("#resultsTable").addClass("hide");
}
let rows = "";
for (const file of files) {
rows += "<tr>";
rows += `
<td>${file.name}</td>
<td>${file.client}</td>
<td>${file.value}</td>
<td>${file.contractStart}</td>
<td>${file.contractEnd}</td>
<td>
<a
class="modal-trigger"
data-filename="${file.name}"
data-fileid="${file.id}"
href="#filePreviewModal"
>プレビュー
</a></td>
`;
rows += "</tr>";
}
$("#resultsTableBody").append(rows);
$("#resultsTable").removeClass("hide");
}
// エラーがあった場合、エラーメッセージを表示する
function onFailure(error) {
finishProcessing();
$("#statusMessage").append(
`
<div class="card-panel red darken-2">
<span class="white-text">
${error.message}
</span>
</div>
`
);
}
// 処理中のUI要素を表示する
function startProcessing() {
$("#statusMessage").empty();
$("#search").addClass("disabled");
$("#resultsTable").addClass("hide");
$("#resultsTableBody").empty();
$("#search").after(
`
<div style="padding-top: 20px" id="processingLoader" >
<div class="progress">
<div class="indeterminate"></div>
</div>
</div>
`
);
}
// 処理が完了したら、処理中の要素を隠す
function finishProcessing(toastMessage) {
$("#processingLoader").remove();
$("#search").removeClass("disabled");
}
</script>
</body>
</html>
このアプリはUrlFetchApp
クラスを利用するため、「外部サービスへの接続」スコープの利用を許可する必要があります。許可するには、スクリプトを実行し、許可画面が表示されます。
デプロイ
スクリプトをウェブアプリとしてデプロイするには、スクリプトエディターの右上にある「デプロイ」ボタンを押下し、「新しいデプロイ」を選択します。
表示される「新しいデプロイ」画面の左側にある設定アイコンを押下し、「ウェブアプリ」を選択し、任意のアプリ説明文を入力後、「デプロイ」ボタンを押下します。それで、ウェブアプリURLが生成され、そのURLでアプリをアクセスすることができます。
まとめ
すぐにデGoogle Apps Script(GAS)プロイできるアプリからBox上のファイルをメタデータで検索する方法を紹介しました。
今回はGASで実装しましたが、サーバーレス関数(例:Google Cloud Functions、Amazon Lambda)や通常のウェブアプリの場合でも同じような手法が使えます。
外部アプリから、Boxアカウントがなくても、Box内のファイルをアクセスし、Boxの使い方が広がります。