Node.js と Cloud Run を使用して Google Workspace アドオンを構築する

1. はじめに

Google Workspace アドオンは、Gmail、ドキュメント、スプレッドシート、スライドなどの Google Workspace アプリケーションと統合されるカスタマイズされたアプリケーションです。これによりデベロッパーは、カスタマイズされたユーザー インターフェースを作成して、Google Workspace に直接統合できます。アドオンにより、ユーザーはコンテキストの切り替えを減らして、より効率的に作業できるようになります。

この Codelab では、Node.js、Cloud RunDatastore を使用してシンプルなタスクリスト アドオンを作成し、デプロイする方法を学びます。

学習内容

  • Cloud Shell を使用する
  • Cloud Run へのデプロイ
  • アドオンのデプロイ記述子を作成してデプロイする
  • カード フレームワークを使用してアドオン UI を作成する
  • ユーザー操作への応答
  • ユーザーのコンテキストをアドオンで活用

2. 設定と要件

設定手順に沿って Google Cloud プロジェクトを作成し、アドオンが使用する API とサービスを有効にします。

セルフペース型の環境設定

  1. Cloud コンソールを開き、新しいプロジェクトを作成します。(Gmail アカウントも Google Workspace アカウントもまだお持ちでない場合は、アカウントを作成してください)。

[プロジェクトを選択] メニュー

[新しいプロジェクト] ボタン

プロジェクト ID

プロジェクト ID を忘れないようにしてください。プロジェクト ID はすべての Google Cloud プロジェクトを通じて一意の名前にする必要があります(上記の名前はすでに使用されているので使用できません)。以降、このコードラボでは PROJECT_ID と呼びます。

  1. 次に、Google Cloud リソースを使用するために、Cloud コンソールで課金を有効にします

この Codelab の操作をすべて行って、費用が生じたとしても、少額です。「クリーンアップ」セクションのセクションでは、このチュートリアルの終了後に課金が発生しないようにリソースをシャットダウンする方法について説明します。Google Cloud の新規ユーザーは、300 米ドル分の無料トライアル プログラムをご利用いただけます。

Google Cloud Shell

Google Cloud はノートパソコンからリモートで操作できますが、この Codelab では Cloud 上で動作するコマンドライン環境である Google Cloud Shell を使用します。

Cloud Shell をアクティブにする

  1. Cloud Console で、[Cloud Shell をアクティブにする] [Cloud Shell] アイコン をクリックします。

メニューバーの [Cloud Shell] アイコン

Cloud Shell を初めて開くと、説明的なウェルカム メッセージが表示されます。ウェルカム メッセージが表示された場合は、[続行] をクリックします。ウェルカム メッセージは今後表示されません。ウェルカム メッセージは次のとおりです。

Cloud Shell のウェルカム メッセージ

すぐにプロビジョニングされて Cloud Shell に接続されます。接続後、Cloud Shell ターミナルが表示されます。

Cloud Shell ターミナル

この仮想マシンには、必要な開発ツールがすべて含まれています。仮想マシンは Google Cloud で稼働し、永続的なホーム ディレクトリが 5 GB 用意されているため、ネットワークのパフォーマンスと認証が大幅に向上しています。この Codelab のすべての作業は、ブラウザまたは Chromebook を使用して行うことができます。

Cloud Shell に接続すると、すでに認証は完了しており、プロジェクトに各自のプロジェクト ID が設定されていることがわかります。

  1. Cloud Shell で次のコマンドを実行して、認証されたことを確認します。
gcloud auth list

Cloud Shell を承認して GCP API 呼び出しを行うように求められたら、[承認] をクリックします。

コマンド出力

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

アクティブなアカウントを設定するには、以下を実行します

gcloud config set account <ACCOUNT>

正しいプロジェクトが選択されていることを確認するために、Cloud Shell で次のコマンドを実行します。

gcloud config list project

コマンド出力

[core]
project = <PROJECT_ID>

正しいプロジェクトが返されない場合は、次のコマンドを使用してプロジェクトを設定することができます。

gcloud config set project <PROJECT_ID>

コマンド出力

Updated property [core/project].

この Codelab では、コマンドライン操作とファイル編集を組み合わせて使用します。ファイル編集の場合、Cloud Shell ツールバーの右側にある [エディタを開く] ボタンをクリックすると、Cloud Shell に組み込まれたコードエディタを使用できます。Cloud Shell には、vim や emacs などの一般的なエディタも使用できます。

3. Cloud Run、Datastore、アドオン API を有効にする

Cloud APIs を有効にする

Cloud Shell から、使用するコンポーネントの Cloud APIs を有効にします。

gcloud services enable \
  run.googleapis.com \
  cloudbuild.googleapis.com \
  cloudresourcemanager.googleapis.com \
  datastore.googleapis.com \
  gsuiteaddons.googleapis.com

このオペレーションには少し時間がかかることがあります。

完了すると、次のような成功メッセージが表示されます。

Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.

データストア インスタンスを作成する

次に、App Engine を有効にして、Datastore データベースを作成します。Datastore を使用するには、App Engine を有効にする必要がありますが、他の目的には App Engine を使用しません。

gcloud app create --region=us-central
gcloud firestore databases create --type=datastore-mode --region=us-central

このアドオンのデータを実行してアクションを実行するには、ユーザー権限が必要です。これを有効にするには、プロジェクトの同意画面を構成します。この Codelab では、最初に同意画面を内部アプリケーションとして設定します。つまり、一般公開するものではありません。

  1. 新しいタブまたはウィンドウで Google Cloud コンソールを開きます。
  2. [Google Cloud コンソール]の横の下矢印 プルダウン矢印 をクリックし、プロジェクトを選択します。
  3. 左上のメニュー アイコン メニュー アイコン をクリックします。
  4. [API とサービス >認証情報。プロジェクトの [認証情報] ページが表示されます。
  5. [OAuth 同意画面] をクリックします。「OAuth 同意画面」画面が表示されます。
  6. [ユーザーの種類]で[内部] を選択します。@gmail.com アカウントを使用している場合は、[外部] を選択します。
  7. [作成] をクリックします。「アプリの登録を編集する」ページが表示されます。
  8. フォームに入力します。
    • [アプリ名] に「Todo Add-on」と入力します。
    • [User support email] に、個人メールアドレスを入力します。
    • [デベロッパーの連絡先情報] に、個人のメールアドレスを入力します。
  9. [保存して次へ] をクリックします。[スコープ] フォームが表示されます。
  10. [スコープ] フォームで、[保存して次へ] をクリックします。概要が表示されます。
  11. [ダッシュボードに戻る] をクリックします。

4. 最初のアドオンを作成する

プロジェクトを初期化する

まず、Python や Curl など、デプロイします。アドオンは、HTTPS リクエストに応答し、UI と実行するアクションを記述した JSON ペイロードで応答するウェブサービスです。このアドオンでは、Node.js と Express フレームワークを使用します。

このテンプレート プロジェクトを作成するには、Cloud Shell を使用して todo-add-on という名前の新しいディレクトリを作成し、そのディレクトリに移動します。

mkdir ~/todo-add-on
cd ~/todo-add-on

Codelab のすべての作業はこのディレクトリで行います。

Node.js プロジェクトを初期化します。

npm init

NPM がプロジェクト構成に関するいくつかの質問(名前やバージョンなど)を行います。各質問に対して、ENTER を押してデフォルト値を受け入れます。デフォルトのエントリ ポイントとは、次に作成する index.js という名前のファイルです。

次に、Express ウェブ フレームワークをインストールします。

npm install --save express express-async-handler

アドオン バックエンドを作成する

ここで、アプリの作成を開始します。

index.js という名前のファイルを作成します。ファイルを作成するには、Cloud Shell ウィンドウのツールバーにある [エディタを開く] ボタンをクリックして、Cloud Shell エディタを使用します。vim または emacs を使用して、Cloud Shell でファイルを編集、管理することもできます。

index.js ファイルを作成したら、次の内容を追加します。

const express = require('express');
const asyncHandler = require('express-async-handler');

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post("/", asyncHandler(async (req, res) => {
    const card = {
        sections: [{
            widgets: [
                {
                    textParagraph: {
                        text: `Hello world!`
                    }
                },
            ]
        }]
    };
    const renderAction = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(renderAction);
}));

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

サーバーは「Hello World」を表示すること以外は何も行いません。問題ありません。後で機能を追加します。

Cloud Run へのデプロイ

Cloud Run にデプロイするには、アプリをコンテナ化する必要があります。

コンテナを作成する

次の内容を含む Dockerfile という名前の Dockerfile を作成します。

FROM node:12-slim

# Create and change to the app directory.
WORKDIR /usr/src/app

# Copy application dependency manifests to the container image.
# A wildcard is used to ensure copying both package.json AND package-lock.json (when available).
# Copying this first prevents re-running npm install on every code change.
COPY package*.json ./

# Install production dependencies.
# If you add a package-lock.json, speed your build by switching to 'npm ci'.
# RUN npm ci --only=production
RUN npm install --only=production

# Copy local code to the container image.
COPY . ./

# Run the web service on container startup.
CMD [ "node", "index.js" ]

不要なファイルをコンテナから排除する

コンテナを軽量化するために、次の内容を含む .dockerignore ファイルを作成します。

Dockerfile
.dockerignore
node_modules
npm-debug.log

Cloud Build を有効にする

この Codelab では、新機能が追加されるたびにアドオンをビルドしてデプロイします。個別のコマンドを実行してコンテナをビルドし、コンテナ レジスタに push したうえで Cloud Build にデプロイする代わりに、Cloud Build を使用して手順をオーケストレートします。アプリケーションのビルドとデプロイの手順を記述した cloudbuild.yaml ファイルを作成します。

steps:
 # Build the container image
 - name: 'gcr.io/cloud-builders/docker'
   args: ['build', '-t', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME', '.']
 # Push the container image to Container Registry
 - name: 'gcr.io/cloud-builders/docker'
   args: ['push', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME']
 # Deploy container image to Cloud Run
 - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
   entrypoint: gcloud
   args:
   - 'run'
   - 'deploy'
   - '$_SERVICE_NAME'
   - '--image'
   - 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
   - '--region'
   - '$_REGION'
   - '--platform'
   - 'managed'
images:
 - 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
substitutions:
   _SERVICE_NAME: todo-add-on
   _REGION: us-central1

次のコマンドを実行して、アプリをデプロイする権限を Cloud Build に付与します。

PROJECT_ID=$(gcloud config list --format='value(core.project)')
PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
gcloud projects add-iam-policy-binding $PROJECT_ID \
    --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \
    --role=roles/run.admin
gcloud iam service-accounts add-iam-policy-binding \
    $PROJECT_NUMBER-compute@developer.gserviceaccount.com \
    --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \
    --role=roles/iam.serviceAccountUser

アドオン バックエンドをビルドしてデプロイする

ビルドを開始するには、Cloud Shell で次のコマンドを実行します。

gcloud builds submit

完全なビルドとデプロイが完了するまでに数分かかる場合があります(特に初回の場合)。

ビルドが完了したら、サービスがデプロイされたことを確認し、URL を見つけます。次のコマンドを実行します。

gcloud run services list --platform managed

この URL をコピーします。この URL は次のステップで必要となり、Google Workspace にアドオンの呼び出し方法を指示する際に必要になります。

アドオンを登録する

サーバーが稼働するようになったので、アドオンの説明を取得して、Google Workspace がアドオンの表示方法と呼び出し方法を認識できるようにします。

デプロイ記述子を作成する

次の内容の deployment.json ファイルを作成します。URL プレースホルダの代わりに、デプロイしたアプリの URL を使用してください。

{
  "oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.execute",
    "https://www.googleapis.com/auth/calendar.addons.execute"
  ],
  "addOns": {
    "common": {
      "name": "Todo Codelab",
      "logoUrl": "https://raw.githubusercontent.com/webdog/octicons-png/main/black/check.png",
      "homepageTrigger": {
        "runFunction": "URL"
      }
    },
    "gmail": {},
    "drive": {},
    "calendar": {},
    "docs": {},
    "sheets": {},
    "slides": {}
  }
}

次のコマンドを実行してデプロイ記述子をアップロードします。

gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json

アドオン バックエンドへのアクセスを承認する

アドオン フレームワークには、サービスを呼び出す権限も必要です。次のコマンドを実行して、Cloud Run の IAM ポリシーを更新し、Google Workspace がアドオンを呼び出せるようにします。

SERVICE_ACCOUNT_EMAIL=$(gcloud workspace-add-ons get-authorization --format="value(serviceAccountEmail)")
gcloud run services add-iam-policy-binding todo-add-on --platform managed --region us-central1 --role roles/run.invoker --member "serviceAccount:$SERVICE_ACCOUNT_EMAIL"

テスト用のアドオンをインストールする

アカウントに開発モードでアドオンをインストールするには、Cloud Shell で次のコマンドを実行します。

gcloud workspace-add-ons deployments install todo-add-on

新しいタブまたはウィンドウで(Gmail)[https://mail.google.com/] を開きます。右側で、チェックマーク アイコンのあるアドオンを見つけます。

インストール済みアドオンのアイコン

アドオンを開くには、チェックマーク アイコンをクリックします。アドオンを承認するよう求めるメッセージが表示されます。

承認プロンプト

[Authorize Access] をクリックし、ポップアップに表示される承認フローの指示に従います。完了すると、アドオンが自動的に再読み込みされ、「Hello world!」表示されます。

これで、これで、シンプルなアドオンがデプロイされ、インストールされました。次はタスクリスト アプリケーションです。

5. ユーザー ID にアクセスする

アドオンは通常、多くのユーザーが自分や組織の非公開の情報を操作するために使用します。この Codelab では、アドオンは現在のユーザーのタスクのみを表示します。ユーザー ID は、デコードする必要がある ID トークンを介してアドオンに送信されます。

デプロイ記述子にスコープを追加する

ユーザー ID はデフォルトでは送信されません。ユーザーデータであり、アドオンはそのデータへのアクセス許可を必要としています。この権限を取得するには、deployment.json を更新し、アドオンに必要なスコープのリストに openidemail の OAuth スコープを追加します。OAuth スコープを追加すると、次回アドオンを使用するときにアクセスを許可するように求められます。

"oauthScopes": [
      "https://www.googleapis.com/auth/gmail.addons.execute",
      "https://www.googleapis.com/auth/calendar.addons.execute",
      "openid",
      "email"
],

次に、Cloud Shell で次のコマンドを実行して、デプロイ記述子を更新します。

gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json

アドオン サーバーを更新する

ユーザー ID をリクエストするようにアドオンが構成されていても、実装の更新が必要です。

ID トークンを解析する

まず、Google 認証ライブラリをプロジェクトに追加します。

npm install --save google-auth-library

次に、OAuth2Client を要求するように index.js を編集します。

const { OAuth2Client } = require('google-auth-library');

次に、ID トークンを解析するヘルパー メソッドを追加します。

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

ユーザー ID を表示する

タスクリストのすべての機能を追加する前に、ここでチェックポイントを設定することをおすすめします。「Hello world」の代わりにユーザーのメールアドレスと一意の ID を出力するようにアプリのルートを更新します。

app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const card = {
        sections: [{
            widgets: [
                {
                    textParagraph: {
                        text: `Hello ${user.email} ${user.sub}`
                    }
                },
            ]
        }]
    };
    const renderAction = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(renderAction);
}));

これらの変更の結果の index.js ファイルは次のようになります。

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const card = {
        sections: [{
            widgets: [
                {
                    textParagraph: {
                        text: `Hello ${user.email} ${user.sub}`
                    }
                },
            ]
        }]
    };
    const renderAction = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(renderAction);
}));

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

再デプロイしてテストする

アドオンを再ビルドして再デプロイします。Cloud Shell から次のコマンドを実行します。

gcloud builds submit

サーバーが再デプロイされたら、Gmail を開くか再読み込みして、もう一度アドオンを開きます。スコープが変更されたため、アドオンから再承認が求められます。アドオンをもう一度承認します。アドオンが完了すると、メールアドレスとユーザー ID が表示されます。

アドオンでユーザーが識別されたので、タスクリスト機能の追加を開始できます。

6. タスクリストを実装する

この Codelab の初期データモデルは単純で、Task エンティティのリストです。各エンティティには、タスクの説明テキストのプロパティとタイムスタンプが含まれています。

データストア インデックスを作成する

この Codelab の前半で、Datastore がプロジェクトですでに有効になっています。スキーマは必要ありませんが、複合クエリ用にインデックスを明示的に作成する必要があります。インデックスの作成には数分かかるため、最初に行ってください。

次の内容のファイルを index.yaml という名前で作成します。

indexes:
- kind: Task
  ancestor: yes
  properties:
  - name: created

次に、Datastore インデックスを更新します。

gcloud datastore indexes create index.yaml

続行するよう求められたら、キーボードの Enter キーを押します。インデックスの作成はバックグラウンドで行われます。その間に、アドオンコードを更新して「ToDo」を実装します。

アドオン バックエンドを更新する

プロジェクトに Datastore ライブラリをインストールします。

npm install --save @google-cloud/datastore

Datastore に対する読み取りと書き込み

index.js を更新して「to-do リスト」を実装するデータストア ライブラリのインポートとクライアントの作成から始めます。

const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

Datastore からタスクを読み書きするメソッドを追加します。

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

UI のレンダリングを実装する

ほとんどの変更は、アドオン UI に対するものです。これまで、UI から返されたすべてのカードは静的であり、利用可能なデータによって変わることはありませんでした。ここで、カードはユーザーの現在のタスクリストに基づいて動的に構築する必要があります。

この Codelab の UI は、テキスト入力と、完了マークを付けるチェックボックス付きのタスクのリストで構成されています。各プロパティには onChangeAction プロパティもあり、ユーザーがタスクを追加または削除したときにアドオン サーバーにコールバックが行われます。いずれの場合も、更新されたタスクリストで UI を再レンダリングする必要があります。これに対処するために、カード UI を作成する新しいメソッドを導入しましょう。

引き続き index.js を編集し、次のメソッドを追加します。

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    // Input for adding a new task
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        // Create text & checkbox for each task
        tasks.forEach(task => taskListSection.widgets.push({
            decoratedText: {
                text: task.text,
                wrapText: true,
                switchControl: {
                    controlType: 'CHECKBOX',
                    name: 'completedTasks',
                    value: task[datastore.KEY].id,
                    selected: false,
                    onChangeAction: {
                        function: `${baseUrl}/complete`,
                    }
                }
            }
        }));
    } else {
        // Placeholder for empty task list
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    }
    return card;
}

ルートを更新する

Datastore の読み取りと書き込み、および UI の構築を行うためのヘルパー メソッドがあるので、これらをアプリのルートでつなぎます。既存のルートを置き換え、タスクの追加用と削除用の 2 つを追加します。

app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date()
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

完全に機能する最終的な index.js ファイルは次のとおりです。

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date()
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    // Input for adding a new task
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        // Create text & checkbox for each task
        tasks.forEach(task => taskListSection.widgets.push({
            decoratedText: {
                text: task.text,
                wrapText: true,
                switchControl: {
                    controlType: 'CHECKBOX',
                    name: 'completedTasks',
                    value: task[datastore.KEY].id,
                    selected: false,
                    onChangeAction: {
                        function: `${baseUrl}/complete`,
                    }
                }
            }
        }));
    } else {
        // Placeholder for empty task list
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    }
    return card;
}

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

再デプロイしてテストする

アドオンを再ビルドして再デプロイするには、ビルドを開始します。Cloud Shell で、次のコマンドを実行します。

gcloud builds submit

Gmail でアドオンを再読み込みすると、新しい UI が表示されます。少し時間を取って、このアドオンを実際に使ってみましょう。いくつかのタスクを追加するには、入力欄にテキストを入力してキーボードの Enter キーを押し、チェックボックスをオンにします。

アドオンとタスク

必要に応じて、この Codelab の最後のステップに進んでプロジェクトをクリーンアップすることもできます。もう 1 つのステップを行うことで、アドオンについてさらに詳しく学ぶことができます。

7. (省略可)コンテキストの追加

アドオンの最も強力な機能の一つはコンテキストアウェアです。アドオンは、ユーザー権限があれば、ユーザーが閲覧しているメール、カレンダーの予定、ドキュメントなどの Google Workspace のコンテキストにアクセスできます。アドオンでは、コンテンツの挿入などの操作を行うこともできます。この Codelab では、Workspace エディタ(ドキュメント、スプレッドシート、スライド)でコンテキストのサポートを追加し、エディタ内で作成されたタスクに現在のドキュメントを添付できるようにします。タスクが表示されたときにクリックすると、新しいタブでドキュメントが開き、ユーザーはドキュメントに戻り、タスクが完了します。

アドオン バックエンドを更新する

newTask ルートの更新

まず、/newTask ルートを更新して、利用可能な場合はドキュメント ID をタスクに含めます。

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    // Get the current document if it is present
    const editorInfo = event.docs || event.sheets || event.slides;
    let document = null;
    if (editorInfo && editorInfo.id) {
        document = {
            id: editorInfo.id,
        }
    }
    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date(),
        document,
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

新しく作成されたタスクに現在のドキュメント ID が含まれるようになりました。ただし、エディタ内のコンテキストは、デフォルトでは共有されません。他のユーザーデータと同様に、ユーザーはアドオンにデータへのアクセスを許可する必要があります。情報の必要以上の共有を防ぐために、ファイルごとに権限をリクエストして付与することが推奨されます。

UI を更新する

index.js で、buildCard を更新して 2 つの変更を行います。1 つ目は、タスクのレンダリングを更新して、ドキュメントへのリンクが存在する場合はそのリンクを追加することです。2 つ目は、アドオンがエディタでレンダリングされ、ファイルへのアクセス権がまだ付与されていない場合に、オプションの承認プロンプトを表示する方法です。

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        tasks.forEach(task => {
            const widget = {
                decoratedText: {
                    text: task.text,
                    wrapText: true,
                    switchControl: {
                        controlType: 'CHECKBOX',
                        name: 'completedTasks',
                        value: task[datastore.KEY].id,
                        selected: false,
                        onChangeAction: {
                            function: `${baseUrl}/complete`,
                        }
                    }
                }
            };
            // Make item clickable and open attached doc if present
            if (task.document) {
                widget.decoratedText.bottomLabel = 'Click to open  document.';
                const id = task.document.id;
                const url = `https://drive.google.com/open?id=${id}`
                widget.decoratedText.onClick = {
                    openLink: {
                        openAs: 'FULL_SIZE',
                        onClose: 'NOTHING',
                        url: url,
                    }
                }
            }
            taskListSection.widgets.push(widget)
        });
    } else {
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    };

    // Display file authorization prompt if the host is an editor
    // and no doc ID present
    const event = req.body;
    const editorInfo = event.docs || event.sheets || event.slides;
    const showFileAuth = editorInfo && editorInfo.id === undefined;
    if (showFileAuth) {
        card.fixedFooter = {
            primaryButton: {
                text: 'Authorize file access',
                onClick: {
                    action: {
                        function: `${baseUrl}/authorizeFile`,
                    }
                }
            }
        }
    }
    return card;
}

ファイル承認ルートを実装する

認証ボタンによってアプリに新しいルートが追加されるため、それを実装しましょう。このルートでは、ホストアプリ アクションという新しいコンセプトが導入されています。これらは、アドオンのホスト アプリケーションとやり取りするための特別な手順です。この場合は、現在のエディタ ファイルへのアクセス権をリクエストします。

index.js で、/authorizeFile ルートを追加します。

app.post('/authorizeFile', asyncHandler(async (req, res) => {
    const responsePayload = {
        renderActions: {
            hostAppAction: {
                editorAction: {
                    requestFileScopeForActiveDocument: {}
                }
            },
        }
    };
    res.json(responsePayload);
}));

完全に機能する最終的な index.js ファイルは次のとおりです。

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    // Get the current document if it is present
    const editorInfo = event.docs || event.sheets || event.slides;
    let document = null;
    if (editorInfo && editorInfo.id) {
        document = {
            id: editorInfo.id,
        }
    }
    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date(),
        document,
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/authorizeFile', asyncHandler(async (req, res) => {
    const responsePayload = {
        renderActions: {
            hostAppAction: {
                editorAction: {
                    requestFileScopeForActiveDocument: {}
                }
            },
        }
    };
    res.json(responsePayload);
}));

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        tasks.forEach(task => {
            const widget = {
                decoratedText: {
                    text: task.text,
                    wrapText: true,
                    switchControl: {
                        controlType: 'CHECKBOX',
                        name: 'completedTasks',
                        value: task[datastore.KEY].id,
                        selected: false,
                        onChangeAction: {
                            function: `${baseUrl}/complete`,
                        }
                    }
                }
            };
            // Make item clickable and open attached doc if present
            if (task.document) {
                widget.decoratedText.bottomLabel = 'Click to open  document.';
                const id = task.document.id;
                const url = `https://drive.google.com/open?id=${id}`
                widget.decoratedText.onClick = {
                    openLink: {
                        openAs: 'FULL_SIZE',
                        onClose: 'NOTHING',
                        url: url,
                    }
                }
            }
            taskListSection.widgets.push(widget)
        });
    } else {
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    };

    // Display file authorization prompt if the host is an editor
    // and no doc ID present
    const event = req.body;
    const editorInfo = event.docs || event.sheets || event.slides;
    const showFileAuth = editorInfo && editorInfo.id === undefined;
    if (showFileAuth) {
        card.fixedFooter = {
            primaryButton: {
                text: 'Authorize file access',
                onClick: {
                    action: {
                        function: `${baseUrl}/authorizeFile`,
                    }
                }
            }
        }
    }
    return card;
}

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

デプロイ記述子にスコープを追加する

サーバーを再構築する前に、アドオンのデプロイ記述子を更新して、https://www.googleapis.com/auth/drive.file OAuth スコープを含めます。deployment.json を更新して、OAuth スコープのリストに https://www.googleapis.com/auth/drive.file を追加します。

"oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.execute",
    "https://www.googleapis.com/auth/calendar.addons.execute",
    "https://www.googleapis.com/auth/drive.file",
    "openid",
    "email"
]

次の Cloud Shell コマンドを実行して、新しいバージョンをアップロードします。

gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json

再デプロイしてテストする

最後に、サーバーを再構築します。Cloud Shell から、次のコマンドを実行します。

gcloud builds submit

完了したら、Gmail を開くのではなく、既存の Google ドキュメントを開くか、doc.new を開いて新しいドキュメントを作成します。新しいドキュメントを作成する場合は、テキストを入力するか、ファイルに名前を付けます。

アドオンを開きます。アドオンの下部に [Authorize File Access] ボタンが表示されます。ボタンをクリックして、ファイルへのアクセスを承認します。

承認されたら、エディタでタスクを追加します。タスクには、ドキュメントが添付されていることを示すラベルが表示されます。リンクをクリックすると、新しいタブでドキュメントが開きます。もちろん、すでに開いているドキュメントを開くのは少し手間がかかるものです。UI を最適化して現在のドキュメントのリンクを除外する場合は、その点も考慮してください。

8. 完了

これで、Cloud Run を使用して Google Workspace アドオンをビルドし、デプロイしました。Codelab ではアドオン作成の基本コンセプトの多くを説明しましたが、学ぶべきことはまだたくさんあります。以下のリソースを参照して、追加料金の発生を回避するためにプロジェクトをクリーンアップすることを忘れないでください。

クリーンアップ

アカウントからアドオンをアンインストールするには、Cloud Shell で次のコマンドを実行します。

gcloud workspace-add-ons deployments uninstall todo-add-on

このチュートリアルで使用したリソースについて、Google Cloud Platform アカウントに課金されないようにする手順は次のとおりです。

  • Cloud コンソールで [リソースの管理] ページに移動します。左上隅をクリックして、[メニュー] メニュー アイコン > [IAM と管理] > [リソースの管理] をクリックします。
  1. プロジェクト リストで、プロジェクトを選択し、[削除] をクリックします。
  2. ダイアログでプロジェクト ID を入力し、[シャットダウン] をクリックしてプロジェクトを削除します。

その他の情報

  • Google Workspace アドオンの概要
  • Marketplace で既存のアプリやアドオンを見つける