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 の操作をすべて行って、費用が生じたとしても、少額です。Codelab の最後にある「クリーンアップ」セクションの指示に従ってください。ここでは、このチュートリアルを終了した後に課金が発生しないようにリソースをシャットダウンするように指示されています。Google Cloud の新規ユーザーは、300 米ドル分の無料トライアル プログラムをご利用いただけます。

Google Cloud Shell

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

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 API を有効にします。

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. [User Type] で [Internal] を選択します。@gmail.com アカウントを使用している場合は、[外部] を選択します。
  7. [作成] をクリックします。[アプリの登録の編集] ページが表示されます。
  8. フォームに記入します。
    • [アプリ名] に「Todo Add-on」と入力します。
    • [ユーザー サポートメール] に個人用メールアドレスを入力します。
    • [デベロッパーの連絡先情報] に個人メールアドレスを入力します。
  9. [保存して次へ] をクリックします。[スコープ] フォームが表示されます。
  10. [スコープ] フォームで、[保存して次へ] をクリックします。概要が表示されます。
  11. [ダッシュボードに戻る] をクリックします。

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

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

まず、シンプルな「Hello World」アドオンを作成してデプロイします。アドオンは、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 をコピーします。これは、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 キーを押します。インデックスの作成はバックグラウンドで行われます。その間に、アドオンのコードを更新して「todos」を実装します。

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

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

npm install --save @google-cloud/datastore

Datastore への読み取りと書き込み

index.js を更新して「todos」を実装します。まず、datastore ライブラリをインポートしてクライアントを作成します。

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 を開いて新しいドキュメントを作成します。新しいドキュメントを作成する場合は、必ずテキストを入力するか、ファイルに名前を付けます。

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

承認されたら、エディタでタスクを追加します。タスクには、ドキュメントが添付されていることを示すラベルが表示されます。リンクをクリックすると、新しいタブでドキュメントが開きます。もちろん、すでに開いているドキュメントを開くのは少し無駄です。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 で既存のアプリやアドオンを見つける