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. Keduanya memungkinkan developer membuat antarmuka pengguna khusus yang terintegrasi langsung ke Google Workspace. Add-on membantu pengguna bekerja secara lebih efisien dengan lebih sedikit pengalihan 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 serta mengaktifkan API dan layanan yang akan digunakan add-on.

Penyiapan lingkungan mandiri

  1. Buka Konsol Cloud 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 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 seharga $300 USD.

Google Cloud Shell

Meskipun Google Cloud dapat dioperasikan secara jarak jauh dari laptop Anda, dalam codelab ini kita akan menggunakan Google Cloud Shell, yakni 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 Continue. Pesan selamat datang tidak akan muncul lagi. Berikut adalah pesan selamat datang:

Pesan selamat datang Cloud Shell

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

Terminal Cloud Shell

Mesin virtual ini dimuat dengan semua alat pengembangan yang Anda butuhkan. VM 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.

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, di Cloud Shell, jalankan:

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 gabungan operasi command line serta pengeditan file. Untuk pengeditan 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 API Add-on

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 agar dapat menggunakan Datastore, tetapi kami tidak akan menggunakan App Engine untuk hal lainnya.

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

Add-on memerlukan izin pengguna untuk menjalankan dan mengambil tindakan terhadap data mereka. Konfigurasi layar izin project untuk mengaktifkannya. Untuk codelab, Anda akan mengonfigurasi layar izin sebagai aplikasi internal. Artinya, memulai penggunaan bukan untuk distribusi publik.

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

4. Membuat add-on awal

Menginisialisasi 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 mendeskripsikan UI dan tindakan yang harus diambil. Dalam add-on ini, Anda akan menggunakan Node.js dan framework Express.

Untuk membuat project template ini, gunakan Cloud Shell untuk 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

Membuat backend add-on

Saatnya untuk 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 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 tidak apa-apa. Anda akan menambahkan lebih banyak fungsi nanti.

Men-deploy ke Cloud Run

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

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

Mengeluarkan file yang tidak diinginkan dari penampung

Untuk membantu menjaga lampu penampung tetap ringan, buat file .dockerignore yang berisi:

Dockerfile
.dockerignore
node_modules
npm-debug.log

Mengaktifkan 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, mengirimnya ke register container, lalu men-deploy-nya ke Cloud Build, gunakan Cloud Build untuk mengorkestrasi prosedurnya. Buat file cloudbuild.yaml yang berisi petunjuk 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 memberi Cloud Build izin guna 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

Proses build dan deploy secara penuh mungkin memerlukan waktu beberapa menit, terutama saat pertama kali dijalankan.

Setelah build selesai, pastikan 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 ini agar Google Workspace mengetahui cara menampilkan dan memanggil add-on.

Membuat deskriptor deployment

Buat file deployment.json dengan konten berikut. Pastikan untuk menggunakan URL aplikasi yang di-deploy sebagai pengganti 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"

Instal add-on untuk pengujian

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

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 tanda centang.

Ikon add-on yang diinstal

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

Perintah otorisasi

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

Selamat! Sekarang Anda telah memiliki add-on sederhana yang di-deploy dan diinstal. Saatnya mengubahnya menjadi aplikasi daftar tugas!

5. Mengakses identitas pengguna

Add-on biasanya digunakan oleh banyak pengguna untuk menangani informasi yang bersifat pribadi bagi mereka atau organisasi mereka. Dalam codelab ini, add-on hanya akan 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 pengguna adalah data pengguna dan add-on memerlukan izin untuk mengaksesnya. Untuk mendapatkan izin tersebut, update 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 saat mereka menggunakan add-on lagi.

"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

Mengupdate server add-on

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

Mengurai token identitas

Mulailah dengan menambahkan library autentikasi Google ke project:

npm install --save google-auth-library

Kemudian, edit index.js untuk mewajibkan OAuth2Client:

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

Kemudian tambahkan metode helper 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 waktu yang tepat untuk checkpoint sebelum menambahkan semua fungsi daftar tugas. Perbarui rute aplikasi untuk mencetak alamat email dan ID unik pengguna, bukan 'Halo dunia'.

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 ini:

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 menguji

Buat 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 lagi add-on tersebut. Karena cakupannya telah berubah, add-on akan meminta otorisasi ulang. Izinkan add-on lagi, dan setelah selesai add-on akan menampilkan alamat email dan ID pengguna Anda.

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

6. Mengimplementasikan daftar tugas

Model data awal untuk codelab ini sangat sederhana: daftar entity Task, masing-masing dengan properti untuk teks deskriptif tugas dan stempel waktu.

Membuat indeks datastore

Datastore sudah diaktifkan untuk project sebelumnya di codelab. Class ini tidak memerlukan skema, meskipun memerlukan pembuatan indeks secara eksplisit untuk kueri gabungan. Membuat indeks dapat memerlukan waktu beberapa menit, jadi Anda harus melakukannya terlebih dahulu.

Buat file bernama index.yaml dengan hal berikut:

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

Kemudian update indeks Datastore:

gcloud datastore indexes create index.yaml

Jika diminta untuk melanjutkan, tekan ENTER di keyboard. Pembuatan indeks terjadi di latar belakang. Sementara itu, mulailah mengupdate kode add-on untuk menerapkan "daftar tugas".

Mengupdate backend add-on

Instal library Datastore ke project:

npm install --save @google-cloud/datastore

Membaca dan menulis ke Datastore

Update index.js untuk menerapkan "daftar tugas" dimulai 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);
}

Mengimplementasikan rendering UI

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

UI untuk codelab terdiri dari input teks beserta daftar tugas dengan kotak centang untuk menandainya sebagai selesai. Masing-masing juga memiliki properti onChangeAction yang menghasilkan callback ke server add-on saat pengguna menambahkan atau menghapus tugas. Dalam setiap kasus ini, UI harus dirender ulang dengan daftar tugas yang diperbarui. Untuk menangani hal ini, kami akan memperkenalkan metode baru untuk membangun UI kartu.

Lanjutkan mengedit 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

Setelah memiliki metode bantuan untuk membaca dan menulis ke Datastore dan membangun UI, mari kita menghubungkannya dalam rute aplikasi. Ganti rute yang ada dan tambahkan dua rute lainnya: satu untuk menambahkan tugas dan satu lagi 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 final 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 menguji

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 ini. Tambahkan beberapa tugas dengan memasukkan beberapa teks ke dalam input dan menekan ENTER pada {i>keyboard<i} Anda, lalu klik kotak centang untuk menghapusnya.

Add-on dengan tugas

Jika mau, Anda dapat langsung melanjutkan ke langkah terakhir dalam codelab ini dan menyelesaikan project. 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 kontekstual. Dengan izin pengguna, add-on dapat mengakses konteks Google Workspace seperti email yang dilihat pengguna, acara kalender, dan dokumen. Add-on juga dapat mengambil tindakan seperti menyisipkan konten. Dalam codelab ini, Anda akan menambahkan dukungan konteks bagi editor Workspace (Dokumen, Spreadsheet, dan Slide) untuk 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.

Mengupdate backend add-on

Perbarui 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 agar add-on dapat mengakses data. Untuk mencegah pembagian informasi yang berlebihan, pendekatan yang lebih disukai adalah meminta dan memberikan izin per file.

Mengupdate UI

Di index.js, update buildCard untuk membuat dua perubahan. Yang pertama adalah memperbarui rendering tugas untuk menyertakan tautan ke dokumen jika ada. Yang kedua adalah menampilkan permintaan 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 implementasikan. Rute ini memperkenalkan konsep baru, yaitu tindakan aplikasi host. Ini adalah petunjuk khusus untuk berinteraksi dengan aplikasi host add-on. Dalam kasus ini, minta 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 final 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. Update 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 menguji

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

gcloud builds submit

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

Buka add-on. Add-on menampilkan tombol Authorize File Access di bagian bawah add-on. Klik tombol, lalu izinkan akses ke file.

Setelah diberi otorisasi, tambahkan tugas saat berada di editor. Tugas tersebut memiliki label yang menunjukkan bahwa dokumen terlampir. Mengklik link akan membuka dokumen di tab baru. Tentu saja, membuka dokumen yang sudah Anda buka agak konyol. Jika Anda ingin mengoptimalkan UI untuk memfilter link untuk dokumen saat ini, pertimbangkan kredit tambahan tersebut.

8. Selamat

Selamat! Anda telah berhasil membangun dan men-deploy Add-on Google Workpace menggunakan Cloud Run. Meskipun codelab ini membahas banyak konsep inti untuk membangun add-on, masih banyak lagi hal yang dapat 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, jalankan perintah ini di Cloud Shell:

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