Membuat Add-on Google Workspace dengan Node.js dan Cloud Run

1. Pengantar

Add-on Google Workspace adalah aplikasi yang disesuaikan yang terintegrasi dengan aplikasi Google Workspace seperti Gmail, Dokumen, Spreadsheet, dan Slide. Dengan demikian, developer dapat membuat antarmuka pengguna yang disesuaikan dan terintegrasi langsung ke Google Workspace. Add-on membantu pengguna bekerja lebih efisien dengan lebih sedikit peralihan konteks.

Dalam codelab ini, Anda akan mempelajari cara membangun dan men-deploy add-on daftar tugas sederhana menggunakan Node.js, Cloud Run, dan Datastore.

Yang akan Anda pelajari

  • Menggunakan Cloud Shell
  • Men-deploy ke Cloud Run
  • Membuat dan men-deploy deskriptor deployment Add-on
  • Membuat UI Add-on dengan framework kartu
  • Merespons interaksi pengguna
  • Memanfaatkan konteks pengguna dalam Add-on

2. Penyiapan dan persyaratan

Ikuti petunjuk penyiapan untuk membuat project Google Cloud dan mengaktifkan API serta layanan yang akan digunakan add-on.

Penyiapan lingkungan mandiri

  1. Buka Cloud Console dan buat project baru. (Jika belum memiliki akun Gmail atau Google Workspace, buat akun.)

Menu pilih project

Tombol Project baru

Project ID

Ingat project ID, nama unik di semua project Google Cloud (maaf, nama di atas telah digunakan dan tidak akan berfungsi untuk Anda!) Project ID tersebut selanjutnya akan dirujuk di codelab ini sebagai PROJECT_ID.

  1. Selanjutnya, untuk menggunakan resource Google Cloud, aktifkan penagihan di Konsol Cloud.

Menjalankan operasi dalam codelab ini seharusnya tidak memerlukan banyak biaya, bahkan mungkin tidak sama sekali. Pastikan untuk mengikuti semua petunjuk di bagian "Pembersihan" di bagian akhir codelab yang memberi tahu Anda cara mematikan resource agar Anda tidak dikenai biaya setelah mengikuti tutorial ini. Pengguna baru Google Cloud memenuhi syarat untuk mengikuti program Uji Coba Gratis senilai $300 USD.

Google Cloud Shell

Meskipun Google Cloud dapat dioperasikan dari jarak jauh menggunakan laptop Anda, dalam codelab ini, kita akan menggunakan Google Cloud Shell, lingkungan command line yang berjalan di Cloud.

Mengaktifkan Cloud Shell

  1. Dari Cloud Console, klik Aktifkan Cloud Shell Ikon Cloud Shell.

Ikon Cloud Shell pada panel menu

Saat pertama kali membuka Cloud Shell, Anda akan melihat pesan selamat datang yang bersifat deskriptif. Jika Anda melihat pesan selamat datang, klik Lanjutkan. Pesan selamat datang tidak akan muncul lagi. Berikut pesan selamat datangnya:

Pesan selamat datang Cloud Shell

Perlu waktu beberapa saat untuk menyediakan dan terhubung ke Cloud Shell. Setelah terhubung, Anda akan melihat Terminal Cloud Shell:

Terminal Cloud Shell

Virtual machine ini dimuat dengan semua alat pengembangan yang Anda perlukan. Layanan ini menawarkan direktori beranda tetap sebesar 5 GB dan beroperasi di Google Cloud, sehingga sangat meningkatkan performa dan autentikasi jaringan. Semua pekerjaan Anda dalam codelab ini dapat dilakukan dengan browser atau Chromebook Anda.

Setelah terhubung ke Cloud Shell, Anda akan melihat bahwa Anda sudah diautentikasi dan project sudah ditetapkan ke project ID Anda.

  1. Jalankan perintah berikut di Cloud Shell untuk mengonfirmasi bahwa Anda telah diautentikasi:
gcloud auth list

Jika Anda diminta untuk mengotorisasi Cloud Shell agar dapat melakukan panggilan API GCP, klik Authorize.

Output perintah

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

Untuk menetapkan akun aktif, jalankan:

gcloud config set account <ACCOUNT>

Untuk mengonfirmasi bahwa Anda telah memilih project yang benar, jalankan perintah berikut di Cloud Shell:

gcloud config list project

Output perintah

[core]
project = <PROJECT_ID>

Jika project yang benar tidak dihasilkan, Anda dapat menyetelnya dengan perintah ini:

gcloud config set project <PROJECT_ID>

Output perintah

Updated property [core/project].

Codelab ini menggunakan campuran operasi command line dan pengeditan file. Untuk mengedit file, Anda dapat menggunakan editor kode bawaan di Cloud Shell dengan mengklik tombol Open Editor di sebelah kanan toolbar Cloud Shell. Anda juga akan menemukan editor populer seperti vim dan emacs yang tersedia di Cloud Shell.

3. Mengaktifkan Cloud Run, Datastore, dan Add-on API

Aktifkan Cloud API

Dari Cloud Shell, aktifkan Cloud API untuk komponen yang akan digunakan:

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

Operasi ini mungkin memerlukan waktu beberapa saat sampai selesai.

Setelah selesai, akan muncul pesan sukses yang mirip dengan yang berikut ini:

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

Membuat instance datastore

Selanjutnya, aktifkan App Engine dan buat database Datastore. Mengaktifkan App Engine adalah prasyarat untuk menggunakan Datastore, tetapi kita tidak akan menggunakan App Engine untuk hal lain.

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

Add-on memerlukan izin pengguna untuk dijalankan dan melakukan tindakan pada datanya. Konfigurasi layar izin project untuk mengaktifkan hal ini. Untuk codelab ini, Anda akan mengonfigurasi layar izin sebagai aplikasi internal, yang berarti aplikasi ini tidak untuk didistribusikan kepada publik, untuk memulai.

  1. Buka Konsol Google Cloud di tab atau jendela baru.
  2. Di samping "Google Cloud Console", klik Panah bawah Panah drop-down, lalu pilih project Anda.
  3. Di sudut kiri atas, klik Menu ikon menu.
  4. Klik API & Layanan > Kredensial. Halaman kredensial untuk project Anda akan muncul.
  5. Klik OAuth consent screen. Layar "OAuth consent screen" akan muncul.
  6. Di bagian "Jenis Pengguna", pilih Internal. Jika menggunakan akun @gmail.com, pilih Eksternal.
  7. Klik Buat. Halaman "Edit pendaftaran aplikasi" akan muncul.
  8. Isi formulir:
    • Di App name, masukkan "Todo Add-on".
    • Di bagian User support email, masukkan alamat email pribadi Anda.
    • Di bagian Informasi kontak developer, masukkan alamat email pribadi Anda.
  9. Klik Simpan dan Lanjutkan. Formulir Cakupan akan muncul.
  10. Dari formulir Scopes, klik Save and Continue. Ringkasan akan muncul.
  11. Klik Kembali ke Dasbor.

4. Membuat add-on awal

Melakukan inisialisasi project

Untuk memulai, Anda akan membuat add-on "Halo dunia" sederhana dan men-deploy-nya. Add-on adalah layanan web yang merespons permintaan https dan merespons dengan payload JSON yang menjelaskan UI dan tindakan yang harus dilakukan. Dalam add-on ini, Anda akan menggunakan Node.js dan framework Express.

Untuk membuat project template ini, gunakan Cloud Shell guna membuat direktori baru bernama todo-add-on, lalu buka direktori tersebut:

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

Anda akan melakukan semua pekerjaan untuk codelab di direktori ini.

Inisialisasi project Node.js:

npm init

NPM akan mengajukan beberapa pertanyaan tentang konfigurasi project, seperti nama dan versi. Untuk setiap pertanyaan, tekan ENTER untuk menyetujui nilai default. Titik entri default adalah file bernama index.js, yang akan kita buat berikutnya.

Selanjutnya, instal framework web Express:

npm install --save express express-async-handler

Buat backend add-on

Saatnya mulai membuat aplikasi.

Buat file bernama index.js. Untuk membuat file, Anda dapat menggunakan Cloud Shell Editor dengan mengklik tombol Open Editor di toolbar jendela Cloud Shell. Atau, Anda dapat mengedit dan mengelola file di Cloud Shell menggunakan vim atau emacs.

Setelah Anda membuat file index.js, tambahkan konten berikut:

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

Server tidak melakukan banyak hal selain menampilkan pesan 'Hello world' dan itu tidak masalah. Anda akan menambahkan lebih banyak fungsi nanti.

Men-deploy ke Cloud Run

Untuk di-deploy di Cloud Run, aplikasi harus di-build dalam container.

Buat penampung

Buat Dockerfile bernama Dockerfile yang berisi:

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

Menjaga agar file yang tidak diinginkan tidak masuk ke dalam container

Untuk membantu menjaga agar container tetap ringan, buat file .dockerignore yang berisi:

Dockerfile
.dockerignore
node_modules
npm-debug.log

Aktifkan Cloud Build

Dalam codelab ini, Anda akan membangun dan men-deploy add-on beberapa kali saat fungsi baru ditambahkan. Daripada menjalankan perintah terpisah untuk membangun container, mengirimkannya ke Container Registry, dan men-deploy-nya ke Cloud Build, gunakan Cloud Build untuk mengatur prosedur. Buat file cloudbuild.yaml dengan petunjuk tentang cara membangun dan men-deploy aplikasi:

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

Jalankan perintah berikut untuk memberikan izin Cloud Build agar dapat men-deploy aplikasi:

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

Membangun dan men-deploy backend add-on

Untuk memulai build, di Cloud Shell, jalankan:

gcloud builds submit

Build dan deployment lengkap mungkin memerlukan waktu beberapa menit untuk diselesaikan, terutama pada saat pertama kali.

Setelah build selesai, verifikasi layanan telah di-deploy dan temukan URL-nya. Jalankan perintah:

gcloud run services list --platform managed

Salin URL ini, Anda akan memerlukannya untuk langkah berikutnya – memberi tahu Google Workspace cara memanggil add-on.

Mendaftarkan add-on

Setelah server aktif dan berjalan, jelaskan add-on agar Google Workspace mengetahui cara menampilkan dan memanggilnya.

Membuat deskriptor deployment

Buat file deployment.json dengan konten berikut. Pastikan untuk menggunakan URL aplikasi yang di-deploy, bukan placeholder 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": {}
  }
}

Upload deskriptor deployment dengan menjalankan perintah:

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

Mengizinkan akses ke backend add-on

Framework add-on juga memerlukan izin untuk memanggil layanan. Jalankan perintah berikut untuk memperbarui kebijakan IAM untuk Cloud Run agar Google Workspace dapat memanggil 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"

Menginstal add-on untuk pengujian

Untuk menginstal add-on dalam mode pengembangan untuk akun Anda, di Cloud Shell, jalankan:

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

Buka (Gmail)[https://mail.google.com/] di tab atau jendela baru. Di sisi kanan, temukan add-on dengan ikon centang.

Ikon add-on yang diinstal

Untuk membuka add-on, klik ikon tanda centang. Perintah untuk mengizinkan add-on akan muncul.

Dialog otorisasi

Klik Otorisasi Akses dan ikuti petunjuk alur otorisasi di pop-up. Setelah selesai, add-on akan otomatis dimuat ulang dan menampilkan pesan 'Hello world!'.

Selamat! Sekarang Anda telah men-deploy dan menginstal add-on sederhana. Saatnya mengubahnya menjadi aplikasi daftar tugas.

5. Mengakses identitas pengguna

Add-on biasanya digunakan oleh banyak pengguna untuk memproses informasi yang bersifat pribadi bagi mereka atau organisasi mereka. Dalam codelab ini, add-on hanya boleh menampilkan tugas untuk pengguna saat ini. Identitas pengguna dikirim ke add-on melalui token identitas yang perlu didekode.

Menambahkan cakupan ke deskriptor deployment

Identitas pengguna tidak dikirim secara default. Data tersebut adalah data pengguna dan add-on memerlukan izin untuk mengaksesnya. Untuk mendapatkan izin tersebut, perbarui deployment.json dan tambahkan cakupan OAuth openid dan email ke daftar cakupan yang diperlukan add-on. Setelah menambahkan cakupan OAuth, add-on akan meminta pengguna untuk memberikan akses pada saat mereka menggunakan add-on berikutnya.

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

Kemudian, di Cloud Shell, jalankan perintah ini untuk memperbarui deskriptor deployment:

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

Memperbarui server add-on

Meskipun add-on dikonfigurasi untuk meminta identitas pengguna, implementasinya masih perlu diperbarui.

Mengurai token identitas

Mulai dengan menambahkan library autentikasi Google ke project:

npm install --save google-auth-library

Kemudian, edit index.js agar memerlukan OAuth2Client:

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

Kemudian, tambahkan metode bantuan untuk mengurai token ID:

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

Menampilkan identitas pengguna

Ini adalah saat yang tepat untuk membuat titik pemeriksaan sebelum menambahkan semua fungsi daftar tugas. Perbarui rute aplikasi untuk mencetak alamat email dan ID unik pengguna, bukan ‘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);
}));

Setelah perubahan ini, file index.js yang dihasilkan akan terlihat seperti:

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

Men-deploy ulang dan melakukan pengujian

Bangun ulang dan deploy ulang add-on. Dari Cloud Shell, jalankan:

gcloud builds submit

Setelah server di-deploy ulang, buka atau muat ulang Gmail, lalu buka add-on lagi. Karena cakupan telah berubah, add-on akan meminta otorisasi ulang. Otorisasi add-on lagi, dan setelah selesai, add-on akan menampilkan alamat email dan ID pengguna Anda.

Setelah add-on mengetahui siapa pengguna tersebut, Anda dapat mulai menambahkan fungsi daftar tugas.

6. Menerapkan daftar tugas

Model data awal untuk codelab ini cukup sederhana: daftar entitas Task, yang masing-masing memiliki properti untuk teks deskriptif tugas dan stempel waktu.

Membuat indeks datastore

Datastore sudah diaktifkan untuk project sebelumnya di codelab. Tidak memerlukan skema, meskipun memerlukan pembuatan indeks secara eksplisit untuk kueri gabungan. Pembuatan indeks dapat memerlukan waktu beberapa menit, jadi Anda akan melakukannya terlebih dahulu.

Buat file bernama index.yaml dengan konten berikut:

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

Kemudian, perbarui indeks Datastore:

gcloud datastore indexes create index.yaml

Saat diminta untuk melanjutkan, tekan ENTER di keyboard Anda. Pembuatan indeks terjadi di latar belakang. Saat itu terjadi, mulailah memperbarui kode add-on untuk menerapkan "todos".

Memperbarui backend add-on

Instal library Datastore ke project:

npm install --save @google-cloud/datastore

Membaca dan menulis ke Datastore

Perbarui index.js untuk menerapkan "todos" dengan mengimpor library datastore dan membuat klien:

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

Tambahkan metode untuk membaca dan menulis tugas dari 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);
}

Menerapkan rendering UI

Sebagian besar perubahan dilakukan pada UI add-on. Sebelumnya, semua kartu yang ditampilkan oleh UI bersifat statis – tidak berubah bergantung pada data yang tersedia. Di sini, kartu harus dibuat secara dinamis berdasarkan daftar tugas pengguna saat ini.

UI untuk codelab terdiri dari input teks beserta daftar tugas dengan kotak centang untuk menandai tugas sebagai selesai. Setiap objek ini juga memiliki properti onChangeAction yang menghasilkan callback ke server add-on saat pengguna menambahkan atau menghapus tugas. Dalam setiap kasus ini, UI perlu dirender ulang dengan daftar tugas yang diperbarui. Untuk menanganinya, mari kita perkenalkan metode baru untuk membangun UI kartu.

Lanjutkan pengeditan index.js dan tambahkan metode berikut:

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

Memperbarui rute

Sekarang setelah ada metode helper untuk membaca dan menulis ke Datastore serta mem-build UI, mari kita hubungkan semuanya dalam rute aplikasi. Ganti rute yang ada dan tambahkan dua rute lagi: satu untuk menambahkan tugas dan satu untuk menghapusnya.

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

Berikut file index.js akhir yang berfungsi penuh:

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

Men-deploy ulang dan melakukan pengujian

Untuk membangun ulang dan men-deploy ulang add-on, mulai build. Di Cloud Shell, jalankan:

gcloud builds submit

Di Gmail, muat ulang add-on dan UI baru akan muncul. Luangkan waktu sejenak untuk menjelajahi add-on. Tambahkan beberapa tugas dengan memasukkan beberapa teks ke dalam input dan menekan ENTER di keyboard, lalu klik kotak centang untuk menghapusnya.

Add-on dengan tugas

Jika mau, Anda dapat langsung menuju langkah terakhir dalam codelab ini dan membersihkan project Anda. Atau, jika Anda ingin terus mempelajari add-on lebih lanjut, ada satu langkah lagi yang dapat Anda selesaikan.

7. (Opsional) Menambahkan konteks

Salah satu fitur add-on yang paling canggih adalah kemampuan untuk memahami konteks. Add-on dapat, dengan izin pengguna, mengakses konteks Google Workspace seperti email yang sedang dilihat pengguna, acara kalender, dan dokumen. Add-on juga dapat melakukan tindakan seperti menyisipkan konten. Dalam codelab ini, Anda akan menambahkan dukungan konteks untuk editor Workspace (Dokumen, Spreadsheet, dan Slide) guna melampirkan dokumen saat ini ke tugas apa pun yang dibuat saat berada di editor. Saat tugas ditampilkan, mengkliknya akan membuka dokumen di tab baru untuk membawa pengguna kembali ke dokumen guna menyelesaikan tugasnya.

Memperbarui backend add-on

Memperbarui rute newTask

Pertama, perbarui rute /newTask untuk menyertakan ID dokumen dalam tugas jika tersedia:

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

Tugas yang baru dibuat kini menyertakan ID dokumen saat ini. Namun, konteks di editor tidak dibagikan secara default. Seperti data pengguna lainnya, pengguna harus memberikan izin kepada add-on untuk mengakses data. Untuk mencegah berbagi informasi secara berlebihan, pendekatan yang lebih baik adalah meminta dan memberikan izin per file.

Mengupdate UI

Di index.js, perbarui buildCard untuk melakukan dua perubahan. Yang pertama adalah memperbarui rendering tugas untuk menyertakan link ke dokumen jika ada. Yang kedua adalah menampilkan dialog otorisasi opsional jika add-on dirender di editor dan akses file belum diberikan.

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

Menerapkan rute otorisasi file

Tombol otorisasi menambahkan rute baru ke aplikasi, jadi mari kita terapkan. Rute ini memperkenalkan konsep baru, yaitu tindakan aplikasi host. Ini adalah petunjuk khusus untuk berinteraksi dengan aplikasi host add-on. Dalam hal ini, untuk meminta akses ke file editor saat ini.

Di index.js, tambahkan rute /authorizeFile:

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

Berikut file index.js akhir yang berfungsi penuh:

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

Menambahkan cakupan ke deskriptor deployment

Sebelum membangun ulang server, perbarui deskriptor deployment add-on untuk menyertakan cakupan OAuth https://www.googleapis.com/auth/drive.file. Perbarui deployment.json untuk menambahkan https://www.googleapis.com/auth/drive.file ke daftar cakupan OAuth:

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

Upload versi baru dengan menjalankan perintah Cloud Shell ini:

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

Men-deploy ulang dan melakukan pengujian

Terakhir, bangun kembali server. Dari Cloud Shell, jalankan:

gcloud builds submit

Setelah selesai, alih-alih membuka Gmail, buka dokumen Google yang ada atau buat dokumen baru dengan membuka doc.new. Jika membuat dokumen baru, pastikan untuk memasukkan beberapa teks atau memberi nama file.

Buka add-on. Add-on menampilkan tombol Izinkan Akses File di bagian bawah add-on. Klik tombol, lalu otorisasi akses ke file.

Setelah diberi otorisasi, tambahkan tugas saat berada di editor. Tugas ini menampilkan label yang menunjukkan bahwa dokumen dilampirkan. Mengklik link akan membuka dokumen di tab baru. Tentu saja, membuka dokumen yang sudah Anda buka sedikit tidak masuk akal. Jika Anda ingin mengoptimalkan UI untuk memfilter link untuk dokumen saat ini, anggap saja itu poin tambahan.

8. Selamat

Selamat! Anda telah berhasil membangun dan men-deploy Add-on Google Workspace menggunakan Cloud Run. Meskipun codelab ini membahas banyak konsep inti untuk membangun add-on, masih banyak lagi hal yang bisa dipelajari. Baca referensi di bawah dan jangan lupa untuk menghapus project Anda agar terhindar dari biaya tambahan.

Pembersihan

Untuk meng-uninstal add-on dari akun Anda, di Cloud Shell, jalankan perintah ini:

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

Agar tidak menimbulkan biaya pada akun Google Cloud Platform Anda untuk resource yang digunakan dalam tutorial ini:

  • Di Cloud Console, buka halaman Manage resource. Di pojok kiri atas, klik Menu ikon menu > IAM & Admin > Manage Resources.
  1. Dalam daftar project, pilih project Anda lalu klik Delete.
  2. Pada dialog, ketik project ID, lalu klik Shut down untuk menghapus project.

Pelajari lebih lanjut