1. Введение
Дополнения Google Workspace — это настраиваемые приложения, которые интегрируются с такими приложениями Google Workspace, как Gmail, Документы, Таблицы и Презентации. Они позволяют разработчикам создавать индивидуальные пользовательские интерфейсы, которые напрямую интегрируются в Google Workspace. Дополнения помогают пользователям работать более эффективно с меньшим переключением контекста.
В этой лабораторной работе вы узнаете, как создать и развернуть простую надстройку списка задач с помощью Node.js, Cloud Run и Datastore .
Что вы узнаете
- Используйте облачную оболочку
- Развертывание в Cloud Run
- Создайте и разверните дескриптор развертывания надстройки.
- Создавайте дополнительные пользовательские интерфейсы с помощью платформы карточек.
- Реагируйте на действия пользователя
- Используйте пользовательский контекст в надстройке
2. Настройка и требования
Следуйте инструкциям по настройке, чтобы создать проект Google Cloud и включить API и сервисы, которые будет использовать надстройка.
Самостоятельная настройка среды
- Откройте Cloud Console и создайте новый проект. (Если у вас еще нет учетной записи Gmail или Google Workspace, создайте ее .)
Запомните идентификатор проекта — уникальное имя для всех проектов Google Cloud (имя, указанное выше, уже занято и не подойдет вам, извините!). Позже в этой лаборатории он будет называться PROJECT_ID
.
- Далее, чтобы использовать ресурсы Google Cloud, включите биллинг в Cloud Console.
Прохождение этой лаборатории кода не должно стоить много, если вообще стоит. Обязательно следуйте всем инструкциям в разделе «Очистка» в конце лабораторной работы по коду, в котором рассказывается, как отключить ресурсы, чтобы вам не приходилось платить за выставление счетов за пределами этого руководства. Новые пользователи Google Cloud имеют право на участие в программе бесплатной пробной версии стоимостью 300 долларов США .
Google Cloud Shell
Хотя Google Cloud можно управлять удаленно с вашего ноутбука, в этой лаборатории мы будем использовать Google Cloud Shell , среду командной строки, работающую в облаке.
Активировать Cloud Shell
- В Cloud Console нажмите «Активировать Cloud Shell». .
При первом открытии Cloud Shell вы увидите подробное приветственное сообщение. Если вы видите приветственное сообщение, нажмите «Продолжить» . Приветственное сообщение больше не появляется. Вот приветственное сообщение:
Подготовка и подключение к Cloud Shell займет всего несколько минут. После подключения вы увидите терминал Cloud Shell:
Эта виртуальная машина оснащена всеми необходимыми инструментами разработки. Он предлагает постоянный домашний каталог объемом 5 ГБ и работает в Google Cloud, что значительно повышает производительность сети и аутентификацию. Всю работу в этой лаборатории кода можно выполнять с помощью браузера или Chromebook.
После подключения к Cloud Shell вы увидите, что вы уже прошли аутентификацию и что для проекта уже установлен идентификатор вашего проекта.
- Выполните следующую команду в Cloud Shell, чтобы подтвердить, что вы прошли аутентификацию:
gcloud auth list
Если вам будет предложено авторизовать Cloud Shell для вызова GCP API, нажмите «Авторизовать» .
Вывод команды
Credentialed Accounts ACTIVE ACCOUNT * <my_account>@<my_domain.com>
Чтобы установить активную учетную запись, запустите:
gcloud config set account <ACCOUNT>
Чтобы убедиться, что вы выбрали правильный проект, в Cloud Shell выполните:
gcloud config list project
Вывод команды
[core] project = <PROJECT_ID>
Если правильный проект не возвращен, вы можете установить его с помощью этой команды:
gcloud config set project <PROJECT_ID>
Вывод команды
Updated property [core/project].
В кодовой лаборатории используется сочетание операций командной строки и редактирования файлов. Для редактирования файлов вы можете использовать встроенный редактор кода в Cloud Shell, нажав кнопку «Открыть редактор» в правой части панели инструментов Cloud Shell. В Cloud Shell вы также найдете популярные редакторы, такие как vim и emacs.
3. Включите Cloud Run, Datastore и дополнительные API.
Включить облачные API
В Cloud Shell включите Cloud API для компонентов, которые будут использоваться:
gcloud services enable \ run.googleapis.com \ cloudbuild.googleapis.com \ cloudresourcemanager.googleapis.com \ datastore.googleapis.com \ gsuiteaddons.googleapis.com
Выполнение этой операции может занять несколько минут.
После завершения появится сообщение об успехе, подобное этому:
Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.
Создать экземпляр хранилища данных
Затем включите App Engine и создайте базу данных Datastore . Включение App Engine является обязательным условием для использования Datastore, но мы не будем использовать App Engine ни для чего другого.
gcloud app create --region=us-central gcloud firestore databases create --type=datastore-mode --region=us-central
Создайте экран согласия OAuth
Надстройке требуется разрешение пользователя на запуск и выполнение действий с их данными. Настройте экран согласия проекта, чтобы включить это. Для работы с кодом вы сначала настроите экран согласия как внутреннее приложение, то есть он не предназначен для публичного распространения.
- Откройте Google Cloud Console в новой вкладке или окне.
- Рядом с надписью «Google Cloud Console» нажмите стрелку вниз. и выберите свой проект.
- В левом верхнем углу нажмите Меню .
- Нажмите API и службы > Учетные данные . Появится страница учетных данных для вашего проекта.
- Нажмите «Экран согласия OAuth» . Появится экран «Экран согласия OAuth».
- В разделе «Тип пользователя» выберите « Внутренний ». Если вы используете учетную запись @gmail.com, выберите «Внешний» .
- Нажмите Создать . Появится страница «Изменить регистрацию приложения».
- Заполните форму:
- В названии приложения введите «Дополнение Todo».
- В поле «Электронная почта поддержки пользователей» введите свой личный адрес электронной почты.
- В разделе «Контактная информация разработчика» введите свой личный адрес электронной почты.
- Нажмите «Сохранить и продолжить» . Появится форма Области действия.
- В форме «Области» нажмите «Сохранить и продолжить» . Появится сводка.
- Нажмите «Вернуться на панель управления» .
4. Создайте начальное дополнение
Инициализировать проект
Для начала вы создадите простую надстройку «Hello world» и развернете ее. Надстройки — это веб-службы, которые отвечают на запросы https и отправляют в ответ полезную нагрузку JSON, описывающую пользовательский интерфейс и необходимые действия. В этом дополнении вы будете использовать Node.js и платформу Express .
Чтобы создать этот шаблонный проект, используйте Cloud Shell, чтобы создать новый каталог с именем todo-add-on
и перейдите к нему:
mkdir ~/todo-add-on cd ~/todo-add-on
В этом каталоге вы будете выполнять всю работу по кодовой лаборатории.
Инициализируйте проект Node.js:
npm init
NPM задает несколько вопросов о конфигурации проекта, например об имени и версии. Для каждого вопроса нажмите ENTER
чтобы принять значения по умолчанию. Точкой входа по умолчанию является файл с именем index.js
, который мы создадим дальше.
Затем установите веб-фреймворк Express:
npm install --save express express-async-handler
Создайте дополнительный бэкэнд
Пора приступить к созданию приложения.
Создайте файл с именем index.js
. Для создания файлов вы можете использовать редактор Cloud Shell , нажав кнопку «Открыть редактор» на панели инструментов окна Cloud Shell. Кроме того, вы можете редактировать файлы и управлять ими в Cloud Shell с помощью vim или emacs.
После создания файла index.js
добавьте следующий контент:
const express = require('express');
const asyncHandler = require('express-async-handler');
// Create and configure the app
const app = express();
// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());
// Initial route for the add-on
app.post("/", asyncHandler(async (req, res) => {
const card = {
sections: [{
widgets: [
{
textParagraph: {
text: `Hello world!`
}
},
]
}]
};
const renderAction = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(renderAction);
}));
// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
});
Сервер ничего не делает, кроме как показывает сообщение «Hello world», и это нормально. Вы добавите больше функций позже.
Развертывание в Cloud Run
Для развертывания в Cloud Run приложение необходимо поместить в контейнер.
Создайте контейнер
Создайте файл Dockerfile с именем Dockerfile
содержащий:
FROM node:12-slim
# Create and change to the app directory.
WORKDIR /usr/src/app
# Copy application dependency manifests to the container image.
# A wildcard is used to ensure copying both package.json AND package-lock.json (when available).
# Copying this first prevents re-running npm install on every code change.
COPY package*.json ./
# Install production dependencies.
# If you add a package-lock.json, speed your build by switching to 'npm ci'.
# RUN npm ci --only=production
RUN npm install --only=production
# Copy local code to the container image.
COPY . ./
# Run the web service on container startup.
CMD [ "node", "index.js" ]
Удалите ненужные файлы из контейнера
Чтобы сохранить легкость контейнера, создайте файл .dockerignore
, содержащий:
Dockerfile
.dockerignore
node_modules
npm-debug.log
Включить облачную сборку
В этой лаборатории кода вы будете создавать и развертывать надстройку несколько раз по мере добавления новых функций. Вместо выполнения отдельных команд для сборки контейнера, отправки его в реестр контейнеров и развертывания в Cloud Build используйте Cloud Build для организации процедуры. Создайте файл cloudbuild.yaml
с инструкциями по сборке и развертыванию приложения:
steps:
# Build the container image
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME', '.']
# Push the container image to Container Registry
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME']
# Deploy container image to Cloud Run
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args:
- 'run'
- 'deploy'
- '$_SERVICE_NAME'
- '--image'
- 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
- '--region'
- '$_REGION'
- '--platform'
- 'managed'
images:
- 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
substitutions:
_SERVICE_NAME: todo-add-on
_REGION: us-central1
Выполните следующие команды, чтобы предоставить Cloud Build разрешение на развертывание приложения:
PROJECT_ID=$(gcloud config list --format='value(core.project)') PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)') gcloud projects add-iam-policy-binding $PROJECT_ID \ --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \ --role=roles/run.admin gcloud iam service-accounts add-iam-policy-binding \ $PROJECT_NUMBER-compute@developer.gserviceaccount.com \ --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \ --role=roles/iam.serviceAccountUser
Сборка и развертывание дополнительного бэкэнда
Чтобы начать сборку, в Cloud Shell выполните:
gcloud builds submit
Полная сборка и развертывание может занять несколько минут, особенно в первый раз.
После завершения сборки убедитесь, что служба развернута, и найдите URL-адрес. Запустите команду:
gcloud run services list --platform managed
Скопируйте этот URL-адрес, он понадобится вам на следующем шаге — указании Google Workspace, как вызвать надстройку.
Зарегистрируйте дополнение
Теперь, когда сервер запущен и работает, опишите надстройку, чтобы Google Workspace знал, как ее отображать и вызывать.
Создайте дескриптор развертывания
Создайте файл deployment.json
со следующим содержимым. Обязательно используйте URL-адрес развернутого приложения вместо заполнителя URL
адреса.
{
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute"
],
"addOns": {
"common": {
"name": "Todo Codelab",
"logoUrl": "https://raw.githubusercontent.com/webdog/octicons-png/main/black/check.png",
"homepageTrigger": {
"runFunction": "URL"
}
},
"gmail": {},
"drive": {},
"calendar": {},
"docs": {},
"sheets": {},
"slides": {}
}
}
Загрузите дескриптор развертывания, выполнив команду:
gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json
Разрешить доступ к серверной части дополнения
Платформе надстроек также требуется разрешение на вызов службы. Выполните следующие команды, чтобы обновить политику IAM для Cloud Run и разрешить Google Workspace вызывать надстройку:
SERVICE_ACCOUNT_EMAIL=$(gcloud workspace-add-ons get-authorization --format="value(serviceAccountEmail)")
gcloud run services add-iam-policy-binding todo-add-on --platform managed --region us-central1 --role roles/run.invoker --member "serviceAccount:$SERVICE_ACCOUNT_EMAIL"
Установите дополнение для тестирования
Чтобы установить дополнение в режиме разработки для своей учетной записи, в Cloud Shell выполните:
gcloud workspace-add-ons deployments install todo-add-on
Откройте (Gmail)[https://mail.google.com/] в новой вкладке или окне. Справа найдите дополнение со значком галочки.
Чтобы открыть дополнение, щелкните значок галочки. Появится запрос на авторизацию дополнения.
Нажмите «Авторизовать доступ» и следуйте инструкциям по авторизации во всплывающем окне. После завершения надстройка автоматически перезагружается и отображает сообщение «Hello world!». сообщение.
Поздравляем! Теперь у вас развернуто и установлено простое дополнение. Пришло время превратить его в приложение со списком задач!
5. Доступ к личности пользователя
Надстройки обычно используются многими пользователями для работы с информацией, которая является частной для них или их организаций. В этой кодовой лаборатории надстройка должна отображать только задачи текущего пользователя. Идентификатор пользователя отправляется в надстройку через идентификационный токен, который необходимо декодировать.
Добавьте области в дескриптор развертывания
Идентификатор пользователя не отправляется по умолчанию. Это пользовательские данные, и надстройке требуется разрешение на доступ к ним. Чтобы получить это разрешение, обновите deployment.json
и добавьте области OAuth openid
и email
в список областей, требуемых надстройке. После добавления областей OAuth надстройка предлагает пользователям предоставить доступ при следующем использовании надстройки.
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute",
"openid",
"email"
],
Затем в Cloud Shell выполните следующую команду, чтобы обновить дескриптор развертывания:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Обновите дополнительный сервер
Хотя надстройка настроена на запрос удостоверения пользователя, реализацию все равно необходимо обновить.
Разобрать идентификационный токен
Начните с добавления в проект библиотеки аутентификации Google:
npm install --save google-auth-library
Затем отредактируйте index.js
, чтобы он требовал OAuth2Client
:
const { OAuth2Client } = require('google-auth-library');
Затем добавьте вспомогательный метод для анализа токена идентификатора:
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
Отображение личности пользователя
Это хорошее время для проверки перед добавлением всех функций списка задач. Обновите маршрут приложения, чтобы он печатал адрес электронной почты и уникальный идентификатор пользователя вместо «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);
}));
После этих изменений результирующий файл index.js
должен выглядеть так:
const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
// Create and configure the app
const app = express();
// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());
// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const card = {
sections: [{
widgets: [
{
textParagraph: {
text: `Hello ${user.email} ${user.sub}`
}
},
]
}]
};
const renderAction = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(renderAction);
}));
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
});
Повторное развертывание и тестирование
Пересоберите и повторно разверните аддон. Из Cloud Shell запустите:
gcloud builds submit
После повторного развертывания сервера откройте или перезагрузите Gmail и снова откройте надстройку. Поскольку области действия изменились, надстройка запросит повторную авторизацию. Авторизуйте надстройку еще раз, и после завершения надстройка отобразит ваш адрес электронной почты и идентификатор пользователя.
Теперь, когда надстройка знает, кто является пользователем, вы можете приступить к добавлению функции списка задач.
6. Реализуйте список задач
Исходная модель данных для лаборатории кода проста: список сущностей Task
, каждый из которых имеет свойства для текста описания задачи и метку времени.
Создайте индекс хранилища данных
Хранилище данных уже было включено для проекта ранее в лаборатории кода. Для этого не требуется схема, но требуется явное создание индексов для составных запросов. Создание индекса может занять несколько минут, поэтому сначала сделайте это.
Создайте файл с именем index.yaml
со следующим:
indexes:
- kind: Task
ancestor: yes
properties:
- name: created
Затем обновите индексы хранилища данных:
gcloud datastore indexes create index.yaml
Когда будет предложено продолжить, нажмите ENTER на клавиатуре. Создание индекса происходит в фоновом режиме. Пока это происходит, начните обновлять код дополнения для реализации «задач».
Обновите серверную часть дополнения
Установите библиотеку Datastore в проект:
npm install --save @google-cloud/datastore
Чтение и запись в хранилище данных
Обновите index.js
, чтобы реализовать «задачи», начиная с импорта библиотеки хранилища данных и создания клиента:
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
Добавьте методы для чтения и записи задач из Datastore:
async function listTasks(userId) {
const parentKey = datastore.key(['User', userId]);
const query = datastore.createQuery('Task')
.hasAncestor(parentKey)
.order('created')
.limit(20);
const [tasks] = await datastore.runQuery(query);
return tasks;;
}
async function addTask(userId, task) {
const key = datastore.key(['User', userId, 'Task']);
const entity = {
key,
data: task,
};
await datastore.save(entity);
return entity;
}
async function deleteTasks(userId, taskIds) {
const keys = taskIds.map(id => datastore.key(['User', userId,
'Task', datastore.int(id)]));
await datastore.delete(keys);
}
Реализовать рендеринг пользовательского интерфейса
Большинство изменений коснулось пользовательского интерфейса дополнения. Раньше все карточки, возвращаемые пользовательским интерфейсом, были статическими — они не менялись в зависимости от доступных данных. Здесь карточку необходимо создавать динамически на основе текущего списка задач пользователя.
Пользовательский интерфейс для лаборатории кода состоит из текстового ввода и списка задач с флажками, чтобы отметить их завершенными. У каждого из них также есть свойство onChangeAction
, которое приводит к обратному вызову на дополнительный сервер, когда пользователь добавляет или удаляет задачу. В каждом из этих случаев пользовательский интерфейс необходимо перерисовать с использованием обновленного списка задач. Чтобы справиться с этой проблемой, давайте представим новый метод создания пользовательского интерфейса карты.
Продолжайте редактировать index.js
и добавьте следующий метод:
function buildCard(req, tasks) {
const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
// Input for adding a new task
const inputSection = {
widgets: [
{
textInput: {
label: 'Task to add',
name: 'newTask',
value: '',
onChangeAction: {
function: `${baseUrl}/newTask`,
},
}
}
]
};
const taskListSection = {
header: 'Your tasks',
widgets: []
};
if (tasks && tasks.length) {
// Create text & checkbox for each task
tasks.forEach(task => taskListSection.widgets.push({
decoratedText: {
text: task.text,
wrapText: true,
switchControl: {
controlType: 'CHECKBOX',
name: 'completedTasks',
value: task[datastore.KEY].id,
selected: false,
onChangeAction: {
function: `${baseUrl}/complete`,
}
}
}
}));
} else {
// Placeholder for empty task list
taskListSection.widgets.push({
textParagraph: {
text: 'Your task list is empty.'
}
});
}
const card = {
sections: [
inputSection,
taskListSection,
]
}
return card;
}
Обновить маршруты
Теперь, когда есть вспомогательные методы для чтения и записи в хранилище данных и создания пользовательского интерфейса, давайте соединим их вместе в маршрутах приложения. Замените существующий маршрут и добавьте еще два: один для добавления задач и один для их удаления.
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(responsePayload);
}));
app.post('/newTask', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const newTask = formInputs.newTask;
if (!newTask || !newTask.stringInputs) {
return {};
}
const task = {
text: newTask.stringInputs.value[0],
created: new Date()
};
await addTask(user.sub, task);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task added.'
},
}
}
};
res.json(responsePayload);
}));
app.post('/complete', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const completedTasks = formInputs.completedTasks;
if (!completedTasks || !completedTasks.stringInputs) {
return {};
}
await deleteTasks(user.sub, completedTasks.stringInputs.value);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task completed.'
},
}
}
};
res.json(responsePayload);
}));
Вот окончательный, полнофункциональный файл index.js
:
const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
// Create and configure the app
const app = express();
// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());
// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(responsePayload);
}));
app.post('/newTask', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const newTask = formInputs.newTask;
if (!newTask || !newTask.stringInputs) {
return {};
}
const task = {
text: newTask.stringInputs.value[0],
created: new Date()
};
await addTask(user.sub, task);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task added.'
},
}
}
};
res.json(responsePayload);
}));
app.post('/complete', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const completedTasks = formInputs.completedTasks;
if (!completedTasks || !completedTasks.stringInputs) {
return {};
}
await deleteTasks(user.sub, completedTasks.stringInputs.value);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task completed.'
},
}
}
};
res.json(responsePayload);
}));
function buildCard(req, tasks) {
const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
// Input for adding a new task
const inputSection = {
widgets: [
{
textInput: {
label: 'Task to add',
name: 'newTask',
value: '',
onChangeAction: {
function: `${baseUrl}/newTask`,
},
}
}
]
};
const taskListSection = {
header: 'Your tasks',
widgets: []
};
if (tasks && tasks.length) {
// Create text & checkbox for each task
tasks.forEach(task => taskListSection.widgets.push({
decoratedText: {
text: task.text,
wrapText: true,
switchControl: {
controlType: 'CHECKBOX',
name: 'completedTasks',
value: task[datastore.KEY].id,
selected: false,
onChangeAction: {
function: `${baseUrl}/complete`,
}
}
}
}));
} else {
// Placeholder for empty task list
taskListSection.widgets.push({
textParagraph: {
text: 'Your task list is empty.'
}
});
}
const card = {
sections: [
inputSection,
taskListSection,
]
}
return card;
}
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
async function listTasks(userId) {
const parentKey = datastore.key(['User', userId]);
const query = datastore.createQuery('Task')
.hasAncestor(parentKey)
.order('created')
.limit(20);
const [tasks] = await datastore.runQuery(query);
return tasks;;
}
async function addTask(userId, task) {
const key = datastore.key(['User', userId, 'Task']);
const entity = {
key,
data: task,
};
await datastore.save(entity);
return entity;
}
async function deleteTasks(userId, taskIds) {
const keys = taskIds.map(id => datastore.key(['User', userId,
'Task', datastore.int(id)]));
await datastore.delete(keys);
}
// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
});
Повторное развертывание и тестирование
Чтобы пересобрать и повторно развернуть надстройку, запустите сборку. В Cloud Shell выполните:
gcloud builds submit
В Gmail перезагрузите надстройку, и появится новый пользовательский интерфейс. Уделите минуту изучению дополнения. Добавьте несколько задач, введя текст и нажав клавишу ВВОД на клавиатуре, затем установите флажок, чтобы удалить их.
Если хотите, вы можете перейти к последнему шагу этой лаборатории кода и очистить свой проект. Или, если вы хотите продолжить узнавать больше о надстройках, вы можете выполнить еще один шаг.
7. (Необязательно) Добавление контекста
Одной из самых мощных функций надстроек является распознавание контекста. С разрешения пользователя дополнения могут получать доступ к контекстам Google Workspace, таким как электронная почта, которую просматривает пользователь, событие календаря и документ. Надстройки также могут выполнять такие действия, как вставка контента. В этой лаборатории кода вы добавите контекстную поддержку для редакторов рабочей области (Документы, Таблицы и Слайды), чтобы прикрепить текущий документ к любым задачам, созданным в редакторах. Когда задача отображается, щелчок по ней откроет документ на новой вкладке, чтобы вернуть пользователя к документу и завершить свою задачу.
Обновите серверную часть дополнения
Обновите маршрут newTask
Сначала обновите маршрут /newTask
, чтобы включить идентификатор документа в задачу, если он доступен:
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);
}));
Вновь созданные задачи теперь включают текущий идентификатор документа. Однако контекст в редакторах по умолчанию не передается. Как и другие пользовательские данные, пользователь должен предоставить надстройке разрешение на доступ к данным. Чтобы предотвратить чрезмерный обмен информацией, предпочтительным подходом является запрос и предоставление разрешения для каждого файла.
Обновите пользовательский интерфейс
В index.js
обновите buildCard
, чтобы внести два изменения. Первый — обновить визуализацию задач, включив в нее ссылку на документ, если он есть. Второй — отобразить дополнительный запрос на авторизацию, если надстройка отображается в редакторе, а доступ к файлу еще не предоставлен.
function buildCard(req, tasks) {
const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
const inputSection = {
widgets: [
{
textInput: {
label: 'Task to add',
name: 'newTask',
value: '',
onChangeAction: {
function: `${baseUrl}/newTask`,
},
}
}
]
};
const taskListSection = {
header: 'Your tasks',
widgets: []
};
if (tasks && tasks.length) {
tasks.forEach(task => {
const widget = {
decoratedText: {
text: task.text,
wrapText: true,
switchControl: {
controlType: 'CHECKBOX',
name: 'completedTasks',
value: task[datastore.KEY].id,
selected: false,
onChangeAction: {
function: `${baseUrl}/complete`,
}
}
}
};
// Make item clickable and open attached doc if present
if (task.document) {
widget.decoratedText.bottomLabel = 'Click to open document.';
const id = task.document.id;
const url = `https://drive.google.com/open?id=${id}`
widget.decoratedText.onClick = {
openLink: {
openAs: 'FULL_SIZE',
onClose: 'NOTHING',
url: url,
}
}
}
taskListSection.widgets.push(widget)
});
} else {
taskListSection.widgets.push({
textParagraph: {
text: 'Your task list is empty.'
}
});
}
const card = {
sections: [
inputSection,
taskListSection,
]
};
// Display file authorization prompt if the host is an editor
// and no doc ID present
const event = req.body;
const editorInfo = event.docs || event.sheets || event.slides;
const showFileAuth = editorInfo && editorInfo.id === undefined;
if (showFileAuth) {
card.fixedFooter = {
primaryButton: {
text: 'Authorize file access',
onClick: {
action: {
function: `${baseUrl}/authorizeFile`,
}
}
}
}
}
return card;
}
Реализовать маршрут авторизации файла
Кнопка авторизации добавляет в приложение новый маршрут, давайте его реализуем. Этот маршрут представляет новую концепцию — действия хост-приложения. Это специальные инструкции по взаимодействию с хост-приложением дополнения. В данном случае запросить доступ к текущему файлу редактора.
В index.js
добавьте маршрут /authorizeFile
:
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
Вот окончательный, полнофункциональный файл index.js
:
const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
// Create and configure the app
const app = express();
// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());
// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(responsePayload);
}));
app.post('/newTask', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const newTask = formInputs.newTask;
if (!newTask || !newTask.stringInputs) {
return {};
}
// Get the current document if it is present
const editorInfo = event.docs || event.sheets || event.slides;
let document = null;
if (editorInfo && editorInfo.id) {
document = {
id: editorInfo.id,
}
}
const task = {
text: newTask.stringInputs.value[0],
created: new Date(),
document,
};
await addTask(user.sub, task);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task added.'
},
}
}
};
res.json(responsePayload);
}));
app.post('/complete', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const completedTasks = formInputs.completedTasks;
if (!completedTasks || !completedTasks.stringInputs) {
return {};
}
await deleteTasks(user.sub, completedTasks.stringInputs.value);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task completed.'
},
}
}
};
res.json(responsePayload);
}));
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
function buildCard(req, tasks) {
const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
const inputSection = {
widgets: [
{
textInput: {
label: 'Task to add',
name: 'newTask',
value: '',
onChangeAction: {
function: `${baseUrl}/newTask`,
},
}
}
]
};
const taskListSection = {
header: 'Your tasks',
widgets: []
};
if (tasks && tasks.length) {
tasks.forEach(task => {
const widget = {
decoratedText: {
text: task.text,
wrapText: true,
switchControl: {
controlType: 'CHECKBOX',
name: 'completedTasks',
value: task[datastore.KEY].id,
selected: false,
onChangeAction: {
function: `${baseUrl}/complete`,
}
}
}
};
// Make item clickable and open attached doc if present
if (task.document) {
widget.decoratedText.bottomLabel = 'Click to open document.';
const id = task.document.id;
const url = `https://drive.google.com/open?id=${id}`
widget.decoratedText.onClick = {
openLink: {
openAs: 'FULL_SIZE',
onClose: 'NOTHING',
url: url,
}
}
}
taskListSection.widgets.push(widget)
});
} else {
taskListSection.widgets.push({
textParagraph: {
text: 'Your task list is empty.'
}
});
}
const card = {
sections: [
inputSection,
taskListSection,
]
};
// Display file authorization prompt if the host is an editor
// and no doc ID present
const event = req.body;
const editorInfo = event.docs || event.sheets || event.slides;
const showFileAuth = editorInfo && editorInfo.id === undefined;
if (showFileAuth) {
card.fixedFooter = {
primaryButton: {
text: 'Authorize file access',
onClick: {
action: {
function: `${baseUrl}/authorizeFile`,
}
}
}
}
}
return card;
}
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
async function listTasks(userId) {
const parentKey = datastore.key(['User', userId]);
const query = datastore.createQuery('Task')
.hasAncestor(parentKey)
.order('created')
.limit(20);
const [tasks] = await datastore.runQuery(query);
return tasks;;
}
async function addTask(userId, task) {
const key = datastore.key(['User', userId, 'Task']);
const entity = {
key,
data: task,
};
await datastore.save(entity);
return entity;
}
async function deleteTasks(userId, taskIds) {
const keys = taskIds.map(id => datastore.key(['User', userId,
'Task', datastore.int(id)]));
await datastore.delete(keys);
}
// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
});
Добавьте области в дескриптор развертывания
Прежде чем перестроить сервер, обновите дескриптор развертывания надстройки, включив в него https://www.googleapis.com/auth/drive.file
область OAuth. Обновите deployment.json
, чтобы добавить https://www.googleapis.com/auth/drive.file
в список областей 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"
]
Загрузите новую версию, выполнив эту команду Cloud Shell:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Повторное развертывание и тестирование
Наконец, перестройте сервер. Из Cloud Shell запустите:
gcloud builds submit
После завершения вместо открытия Gmail откройте существующий документ Google или создайте новый, открыв doc.new . При создании нового документа обязательно введите текст или дайте файлу имя.
Откройте дополнение. Надстройка отображает кнопку «Разрешить доступ к файлу» в нижней части надстройки. Нажмите кнопку, затем разрешите доступ к файлу.
После авторизации добавьте задачу в редакторе. Задача имеет метку, указывающую, что документ прикреплен. При нажатии на ссылку документ открывается в новой вкладке. Конечно, открывать уже открытый документ немного глупо. Если вы хотите оптимизировать пользовательский интерфейс для фильтрации ссылок на текущий документ, считайте это дополнительной заслугой!
8. Поздравления
Поздравляем! Вы успешно создали и развернули надстройку Google Workpace с помощью Cloud Run. Несмотря на то, что в кодовой лаборатории были рассмотрены многие основные концепции создания надстройки, еще многое предстоит изучить. Ознакомьтесь с ресурсами ниже и не забудьте очистить свой проект, чтобы избежать дополнительных расходов.
Очистить
Чтобы удалить надстройку из своей учетной записи, в Cloud Shell выполните следующую команду:
gcloud workspace-add-ons deployments uninstall todo-add-on
Чтобы избежать списания средств с вашей учетной записи Google Cloud Platform за ресурсы, используемые в этом руководстве:
- В Cloud Console перейдите на страницу «Управление ресурсами» . Нажмите В левом верхнем углу нажмите Меню. > IAM и администрирование > Управление ресурсами .
- В списке проектов выберите свой проект и нажмите «Удалить» .
- В диалоговом окне введите идентификатор проекта и нажмите «Завершить работу» , чтобы удалить проект.