arrow_back

Build a Google Workspace Add-on with Node.js and Cloud Run

Join Sign in
Test and share your knowledge with our community!
done
Get access to over 700 hands-on labs, skill badges, and courses

Build a Google Workspace Add-on with Node.js and Cloud Run

Lab 1 hour 30 minutes universal_currency_alt 7 Credits show_chart Advanced
Test and share your knowledge with our community!
done
Get access to over 700 hands-on labs, skill badges, and courses

GSP953

Google Cloud self-paced labs logo

Overview

Google Workspace Add-ons are customized applications that integrate with Google Workspace applications such as Gmail, Docs, Sheets, and Slides. They enable developers to create customized user interfaces that are directly integrated into Google Workspace. Add-ons help users work more efficiently with less context switching.

In this lab, you'll learn how to build and deploy a simple task list add-on using Node.js, Cloud Run, and Datastore.

Objectives

In this lab, you will learn how to:

  • Create and deploy an Add-on deployment descriptor to Cloud Run

  • Create Add-on UIs with the card framework

  • Respond to user interactions

  • Leverage user context in an Add-on

Setup and requirements

Before you click the Start Lab button

Read these instructions. Labs are timed and you cannot pause them. The timer, which starts when you click Start Lab, shows how long Google Cloud resources will be made available to you.

This hands-on lab lets you do the lab activities yourself in a real cloud environment, not in a simulation or demo environment. It does so by giving you new, temporary credentials that you use to sign in and access Google Cloud for the duration of the lab.

To complete this lab, you need:

  • Access to a standard internet browser (Chrome browser recommended).
Note: Use an Incognito or private browser window to run this lab. This prevents any conflicts between your personal account and the Student account, which may cause extra charges incurred to your personal account.
  • Time to complete the lab---remember, once you start, you cannot pause a lab.
Note: If you already have your own personal Google Cloud account or project, do not use it for this lab to avoid extra charges to your account.

How to start your lab and sign in to the Google Cloud Console

  1. Click the Start Lab button. If you need to pay for the lab, a pop-up opens for you to select your payment method. On the left is the Lab Details panel with the following:

    • The Open Google Console button
    • Time remaining
    • The temporary credentials that you must use for this lab
    • Other information, if needed, to step through this lab
  2. Click Open Google Console. The lab spins up resources, and then opens another tab that shows the Sign in page.

    Tip: Arrange the tabs in separate windows, side-by-side.

    Note: If you see the Choose an account dialog, click Use Another Account.
  3. If necessary, copy the Username from the Lab Details panel and paste it into the Sign in dialog. Click Next.

  4. Copy the Password from the Lab Details panel and paste it into the Welcome dialog. Click Next.

    Important: You must use the credentials from the left panel. Do not use your Google Cloud Skills Boost credentials. Note: Using your own Google Cloud account for this lab may incur extra charges.
  5. Click through the subsequent pages:

    • Accept the terms and conditions.
    • Do not add recovery options or two-factor authentication (because this is a temporary account).
    • Do not sign up for free trials.

After a few moments, the Cloud Console opens in this tab.

Note: You can view the menu with a list of Google Cloud Products and Services by clicking the Navigation menu at the top-left. Navigation menu icon

Activate Cloud Shell

Cloud Shell is a virtual machine that is loaded with development tools. It offers a persistent 5GB home directory and runs on the Google Cloud. Cloud Shell provides command-line access to your Google Cloud resources.

  1. Click Activate Cloud Shell Activate Cloud Shell icon at the top of the Google Cloud console.

When you are connected, you are already authenticated, and the project is set to your PROJECT_ID. The output contains a line that declares the PROJECT_ID for this session:

Your Cloud Platform project in this session is set to YOUR_PROJECT_ID

gcloud is the command-line tool for Google Cloud. It comes pre-installed on Cloud Shell and supports tab-completion.

  1. (Optional) You can list the active account name with this command:

gcloud auth list
  1. Click Authorize.

  2. Your output should now look like this:

Output:

ACTIVE: * ACCOUNT: student-01-xxxxxxxxxxxx@qwiklabs.net To set the active account, run: $ gcloud config set account `ACCOUNT`
  1. (Optional) You can list the project ID with this command:

gcloud config list project

Output:

[core] project = <project_ID>

Example output:

[core] project = qwiklabs-gcp-44776a13dea667a6 Note: For full documentation of gcloud, in Google Cloud, refer to the gcloud CLI overview guide.

Task 1. Enable Cloud Run, Datastore, and Add-on APIs

Enable Cloud APIs

  • In Cloud Shell, enable the Cloud APIs for the components that will be used:

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

This operation may take a few moments to complete.

Once completed, a success message similar to this one appears:

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

Click Check my progress to verify that you've performed the above task.

Enable APIs

Create a datastore instance

  • Next, enable App Engine and create a Datastore database. Enabling App Engine is a prerequisite to use Datastore, but you won't use App Engine for anything else.

gcloud app create --region=us-central gcloud datastore databases create --region=us-central

Create an OAuth consent screen

The add-on requires user permission to run and take action on their data. Configure the project's consent screen to enable this. For the lab, you'll configure the consent screen as an internal application, meaning it's not for public distribution, to get started.

  1. At the top-left corner, expand the Navigation Menu (Navigation menu icon).

  2. Click APIs & Services > Credentials. The credential page for your project appears.

  3. Click OAuth consent screen. The "OAuth consent screen" screen appears.

  4. Under "User Type," select External.

  1. Click Create. An "Edit app registration" page appears.

  2. Fill out the form:

    • In App name, enter "Todo Add-on".
    • In User support email, select the student email address from the dropdown.
    • Under Developer contact information, use the same student email address (it's also the GCP Username in the left panel).
    • Leave everything else as default.
  3. Click Save and Continue. A Scopes form appears.

  4. From the Scopes form, leave everything as default and click Save and Continue. A Test Users form appears.

  5. From the Test Users form, leave everything as default and click Save and Continue. A summary appears.

  6. Click Back to Dashboard.

Note: When building a production add-on for the Google Workspace Marketplace, properly filling out the consent screen information is required prior to publishing.

Click Check my progress to verify that you've performed the above task. Create a datastore instance and OAuth consent screen

Task 2. Create the initial add-on

Initialize the project

To begin, you'll create a simple "Hello world" add-on and deploy it. Add-ons are web services that respond to https requests and respond with a JSON payload that describes the UI and actions to take. In this add-on, you'll use Node.js and the Express framework.

  1. To create this template project, use Cloud Shell to create a new directory named todo-add-on and navigate to it:

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

You'll do all the work for the lab in this directory.

  1. Initialize the Node.js project:

npm init

NPM asks several questions about the project configuration, such as name and version.

  1. For each question, press ENTER to accept the default values. The default entry point is a file named index.js, which you'll create next.

  2. Next, update npm and install the Express web framework:

npm install -g npm@8.0.0 npm install --save express express-async-handler

Create the add-on backend

  1. Click the Open Editor button on the toolbar of Cloud Shell. (You can switch between Cloud Shell and the code editor by using the Open Editor and Open Terminal icons as required, or click the Open in new window button to leave the Editor open in a separate tab).

Time to start creating the app.

  1. Inside of the todo-add-on folder, create a file named index.js with the following content:

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}`) });

The server doesn't do much other than show the 'Hello world' message and that's OK. You'll add more functionality later.

Deploy to Cloud Run

To deploy on Cloud Run, the app needs to be containerized.

Create the container

  • Create a Dockerfile named Dockerfile containing:

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" ]

Keep unwanted files out of the container

  • To help keep the container light, create a .dockerignore file containing:

Dockerfile .dockerignore node_modules npm-debug.log

Enable Cloud Build

In this lab you'll build and deploy the add-on several times as new functionality is added. Instead of running separate commands to build the container, push it to the container registry, and deploy it to Cloud Run, use Cloud Build to orchestrate the procedure.

  1. Create a cloudbuild.yaml file with instructions on how to build and deploy the application:

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' - '--max-instances' - '1' images: - 'gcr.io/$PROJECT_ID/$_SERVICE_NAME' substitutions: _SERVICE_NAME: todo-add-on _REGION: us-central1
  1. Return to Cloud Shell and run the following commands to grant Cloud Build permission to deploy the app:

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

Build and deploy the add-on backend

  1. To start the build, in Cloud Shell, run:

gcloud builds submit

The full build and deploy may take a few minutes to complete, particularly the first time around.

  1. Once the build completes, verify the service is deployed and find the URL. Run the command:

gcloud run services list --platform managed
  1. Copy this URL, you'll need it for the next step -- telling Google Workspace how to invoke the add-on.

Click Check my progress to verify that you've performed the above task. Create the add-on backend

Register the add-on

Now that the server is up and running, describe the add-on so Google Workspace knows how to display and invoke it.

Create a deployment descriptor

  1. In the Code Editor, create the file deployment.json with the following content. Make sure to use the URL of the deployed app in place of the URL placeholder.

{ "oauthScopes": [ "https://www.googleapis.com/auth/gmail.addons.execute", "https://www.googleapis.com/auth/calendar.addons.execute" ], "addOns": { "common": { "name": "Todo lab", "logoUrl": "https://raw.githubusercontent.com/webdog/octicons-png/main/black/check.png", "homepageTrigger": { "runFunction": "URL" } }, "gmail": {}, "drive": {}, "calendar": {}, "docs": {}, "sheets": {}, "slides": {} } }
  1. In Cloud Shell, upload the deployment descriptor by running the command:

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

Authorize access to the add-on backend

The add-ons framework also needs permission to call the service.

  • Run the following commands to update the IAM policy for Cloud Run to allow Google Workspace to invoke the add-on:

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"

Install the add-on for testing

  1. To install the add-on in development mode for your account, run:

gcloud workspace-add-ons deployments install todo-add-on
  1. Open Gmail in a new tab or window (you can also find the link in the Lab Panel). On the right-hand side, find the add-on with a checkmark icon.

The highlighted add-on with a checkmark icon

  1. To open the add-on, click the checkmark icon. A prompt to authorize the add-on appears.

The Authorize Access button displayed in the prompt

  1. Click Authorize Access and follow the authorization flow instructions in the popup.

Once complete, the add-on automatically reloads and displays the 'Hello world!' message.

Note: If you get HTTP error response, wait for the IAM policy to update (usually around a minute) and try again.

Congratulations! You now have a simple add-on deployed and installed. Time to turn it into a task list application!

Click Check my progress to verify that you've performed the above task. Register the add-on

Task 3. Access the user identity

Add-ons are typically used by many users to work with information that is private to them or their organizations. In this lab, the add-on should only show the tasks for the current user. The user identity is sent to the add-on via an identity token that needs to be decoded.

Add scopes to the deployment descriptor

  1. The user identity isn't sent by default. It's user data and the add-on needs permission to access it. To gain that permission, update deployment.json and add the openid and email OAuth scopes to the list of scopes the add-on requires. After adding OAuth scopes, the add-on prompts users to grant access the next time they use the add-on.

"oauthScopes": [ "https://www.googleapis.com/auth/gmail.addons.execute", "https://www.googleapis.com/auth/calendar.addons.execute", "openid", "email" ],
  1. Then, in Cloud Shell, run this command to update the deployment descriptor:

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

Update the add-on server

While the add-on is configured to request the user identity, the implementation still needs to be updated.

Parse the identity token

  1. Start by adding the Google auth library to the project:

npm install --save google-auth-library
  1. Then edit index.js to require OAuth2Client:

const { OAuth2Client } = require('google-auth-library');
  1. Then add a helper method to parse the ID token:

async function userInfo(event) { const idToken = event.authorizationEventObject.userIdToken; const authClient = new OAuth2Client(); const ticket = await authClient.verifyIdToken({ idToken }); return ticket.getPayload(); } Note: If you're familiar with identity tokens, you might wonder why the audience is ignored. This is because the request itself is already authenticated as from the add-ons framework and for this add-on specifically.

To add audience verification for the user id token, you'll find the intended audience by running the command gcloud workspace-add-ons get-authorization --format="value(serviceAccountEmail)".

Display the user identity

This is a good time for a checkpoint before adding all of the task list functionality.

  1. Update the app's route to print the user's email address and unique ID instead of 'Hello world':

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); }));

After these changes, the resulting index.js file should look like:

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}`) });

Redeploy and test

  1. Rebuild and redeploy the add-on. From Cloud Shell, run:

gcloud builds submit
  1. Once the server is redeployed, open or reload Gmail and open the add-on again. Since the scopes have changed, the add-on will ask for reauthorization. Authorize the add-on again, and once complete the add-on displays your email address and user ID.

Now that add-on knows who the user is, you can start adding the task list functionality.

Task 4. Implement the task list

The initial data model for the lab is straightforward: a list of Task entities, each with properties for the task descriptive text and a timestamp.

Create the datastore index

Datastore was already enabled for the project earlier in the lab. It doesn't require a schema, though it does require explicitly creating indexes for compound queries. Creating the index can take a few minutes, so you'll do that first.

  1. In the Code Editor, create a file named index.yaml with the following:

indexes: - kind: Task ancestor: yes properties: - name: created
  1. Then, in Cloud Shell, update the Datastore indexes:

gcloud datastore indexes create index.yaml
  1. When prompted to continue, press ENTER on your keyboard. Index creation happens in the background. While that's happening, start updating the add-on code to implement the "todos".

Click Check my progress to verify that you've performed the above task. Implement the task list

Update the add-on backend

  • Install the Datastore library to the project:

npm install --save @google-cloud/datastore

Read and write to Datastore

  1. Update index.js to implement the "todos" beginning with importing the datastore library and creating the client:

const {Datastore} = require('@google-cloud/datastore'); const datastore = new Datastore();
  1. Add methods to read and write tasks from 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); }

Implement rendering of the UI

Most of the changes are to the add-on UI. Earlier, all the cards returned by the UI were static -- they didn't change depending on the data available. Here, the card needs to be constructed dynamically based on the user's current task list.

The UI for the lab consists of a text input along with a list of tasks with check boxes to mark them complete. Each of these also has an onChangeAction property that results in a callback into the add-on server when the user adds or deletes a task. In each of these cases, the UI needs to be re-rendered with the updated task list. To handle this, let's introduce a new method for building the card UI.

  • Continue to edit index.js and add the following method:

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; }

Update the routes

Now that there are helper methods to read and write to Datastore and build the UI, let's wire them together in the app routes.

  • Replace the existing route and add two more: one for adding tasks and one for deleting them:

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); }));

Here's the final, fully functional index.js file:

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}`) });

Redeploy and test

  1. To rebuild and redeploy the add-on, start a build. In Cloud Shell, run:

gcloud builds submit
  1. In Gmail, reload the add-on and the new UI appears. Take a minute to explore the add-on. Add a few tasks by entering some text into the input and pressing ENTER on your keyboard, then click the checkbox to delete them.

The Todo lab task list

Task 5. Adding context

One of the most powerful features of add-ons is context-awareness. Add-ons can, with user permission, access Google Workspace contexts such as the email a user is looking at, a calendar event, and a document. Add-ons can also take actions such as inserting content. In this lab, you'll add context support for the Workspace editors (Docs, Sheets, and Slides) to attach the current document to any tasks created while in the editors. When the task is displayed, clicking on it will then open the document in a new tab to bring the user back to the document to finish their task.

Update the add-on backend

Update the newTask route

  • First, in index.js, update the /newTask route to include the document id in a task if it is available:

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); }));

Newly created tasks now include the current document ID. However, context in the editors is not shared by default. Like other user data, the user must grant permission for the add-on to access the data. To prevent over-sharing of information, the preferred approach is to request and grant permission on a per-file basis.

Update the UI

  • In index.js, update buildCard to make two changes. The first is updating the rendering of the tasks to include a link to the document if present. The second is to display an optional authorization prompt if the add-on is rendered in an editor and file access isn't yet granted.

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; }

Implement the file authorization route

The authorization button adds a new route to the app, so let's implement it. This route introduces a new concept, host app actions. These are special instructions for interacting with the add-on's host application. In this case, to request access to the current editor file.

  • In index.js, add the /authorizeFile route:

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

Here's the final, fully functional index.js file:

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}`) });

Add scopes to the deployment descriptor

  1. Before rebuilding the server, update the add-on deployment descriptor to include the https://www.googleapis.com/auth/drive.file OAuth scope. Update deployment.json to add https://www.googleapis.com/auth/drive.file to the list of OAuth scopes:

"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" ],
  1. In Cloud Shell, upload the new version by running this command:

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

Redeploy and test

  1. Finally, rebuild the server. From Cloud Shell, run:

gcloud builds submit
  1. Once complete, instead of opening Gmail, open an existing Google document or create a new one by opening doc.new. If creating a new doc, be sure to enter some text or give the file a name. Notice the Todo Add-on (the checkmark icon) in the right panel.

  2. Click to open the add-on.

  3. At the bottom of the add-on, click the Authorize File Access to authorize access to the file.

Note: At the time of writing, there's a known issue where add-ons may not load correctly in a new document. If this occurs, reload the window and open the add-on again.
  1. Once authorized, add a task while in the editor. The task features a label indicating that the document is attached. Clicking on the link opens the document in a new tab. Of course, opening the document you already have open is a little silly. If you'd like to optimize the UI to filter out links for the current document, consider that extra credit!

  2. Click the checkbox shown next to your task. This will complete the task from your add-on tasks list.

Click Check my progress to verify that you've performed the above task. Redeploy and test

Clean up (optional)

  • To uninstall the add-on from your account, in Cloud Shell, run this command:

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

Congratulations!

You've successfully built and deployed a Google Workspace Add-on using Cloud Run. While the lab covered many of the core concepts for building an add-on, there's a lot more to explore. See the resources below and don't forget to clean up your project to avoid additional charges.

Finish your quest

This self-paced lab is part of the Workspace Integrations quest. A quest is a series of related labs that form a learning path. Completing this quest earns you a badge to recognize your achievement. You can make your badge or badges public and link to them in your online resume or social media account. Enroll in this quest and get immediate completion credit. Refer to the Google Cloud Skills Boost catalog for all available quests.

Next steps / Learn more

Google Cloud training and certification

...helps you make the most of Google Cloud technologies. Our classes include technical skills and best practices to help you get up to speed quickly and continue your learning journey. We offer fundamental to advanced level training, with on-demand, live, and virtual options to suit your busy schedule. Certifications help you validate and prove your skill and expertise in Google Cloud technologies.

Manual Last Updated December 01, 2022
Lab Last Tested December 01, 2022

Copyright 2022 Google LLC All rights reserved. Google and the Google logo are trademarks of Google LLC. All other company and product names may be trademarks of the respective companies with which they are associated.