1. Introdução
Os complementos do Google Workspace são aplicativos personalizados que se integram aos aplicativos do Google Workspace, como Gmail, Documentos, Planilhas e Apresentações. Com eles, os desenvolvedores podem criar interfaces do usuário personalizadas e integradas diretamente ao Google Workspace. Os complementos ajudam os usuários a trabalhar de forma mais eficiente com menos troca de contexto.
Neste codelab, você vai aprender a criar e implantar um complemento de lista de tarefas simples usando o Node.js, o Cloud Run e o Datastore.
O que você vai aprender
- Como usar o Cloud Shell
- Implantar no Cloud Run
- Criar e implantar um descritor de implantação de complemento
- Criar IUs de complementos com o framework do card
- Responder às interações do usuário
- Aproveitar o contexto do usuário em um complemento
2. Configuração e requisitos
Siga as instruções de configuração para criar um projeto do Google Cloud e ativar as APIs e os serviços que o complemento vai usar.
Configuração de ambiente autoguiada
- Abra o console do Cloud e crie um novo projeto. Se você ainda não tiver uma conta do Gmail ou do Google Workspace, crie uma.
Lembre-se do código do projeto, um nome exclusivo em todos os projetos do Google Cloud. O nome acima já foi escolhido e não servirá para você. Faremos referência a ele mais adiante neste codelab como PROJECT_ID
.
- Em seguida, para usar os recursos do Google Cloud, ative o faturamento no console do Cloud.
A execução deste codelab não será muito cara, se tiver algum custo. Siga todas as instruções da seção "Limpeza" no final do codelab, que contém orientações sobre como encerrar recursos para não gerar cobranças além deste tutorial. Novos usuários do Google Cloud estão qualificados para o programa de US$ 300 de avaliação sem custos.
Google Cloud Shell
Embora o Google Cloud possa ser operado remotamente do seu laptop, neste codelab vamos usar o Google Cloud Shell, um ambiente de linha de comando executado no Cloud.
Ativar o Cloud Shell
- No Console do Cloud, clique em Ativar o Cloud Shell.
Na primeira vez que abrir o Cloud Shell, você verá uma mensagem de boas-vindas descritiva. Se a mensagem de boas-vindas aparecer, clique em Continuar. A mensagem de boas-vindas não vai aparecer de novo. Esta é a mensagem de boas-vindas:
Leva apenas alguns instantes para provisionar e se conectar ao Cloud Shell. Depois de se conectar, você verá o terminal do Cloud Shell:
Essa máquina virtual tem todas as ferramentas de desenvolvimento necessárias. Ela oferece um diretório principal persistente de 5 GB, além de ser executada no Google Cloud. Isso aprimora o desempenho e a autenticação da rede. Todo o trabalho neste codelab pode ser feito em um navegador ou no seu Chromebook.
Depois de se conectar ao Cloud Shell, você vai ver que sua conta já está autenticada e que o projeto está configurado com seu ID do projeto.
- Execute o seguinte comando no Cloud Shell para confirmar se a conta está autenticada:
gcloud auth list
Se você precisar autorizar o Cloud Shell a fazer uma chamada de API do GCP, clique em Autorizar.
Resposta ao comando
Credentialed Accounts ACTIVE ACCOUNT * <my_account>@<my_domain.com>
Para definir a conta ativa, execute:
gcloud config set account <ACCOUNT>
Para confirmar que você selecionou o projeto correto, execute no Cloud Shell:
gcloud config list project
Resposta ao comando
[core] project = <PROJECT_ID>
Se o projeto correto não for retornado, configure-o com este comando:
gcloud config set project <PROJECT_ID>
Resposta ao comando
Updated property [core/project].
O codelab usa uma combinação de operações de linha de comando e edição de arquivos. Para editar arquivos, use o editor de código integrado no Cloud Shell. Para isso, clique no botão Abrir editor no lado direito da barra de ferramentas do Cloud Shell. Você também encontrará editores conhecidos, como vim e emacs, disponíveis no Cloud Shell.
3. Ative o Cloud Run, o Datastore e as APIs de complementos
Ativar Cloud APIs
No Cloud Shell, ative as APIs do Cloud para os componentes que serão usados:
gcloud services enable \ run.googleapis.com \ cloudbuild.googleapis.com \ cloudresourcemanager.googleapis.com \ datastore.googleapis.com \ gsuiteaddons.googleapis.com
A conclusão dessa operação pode levar alguns instantes.
Depois de concluída, uma mensagem de sucesso semelhante a esta aparece:
Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.
Criar uma instância de repositório de dados
Em seguida, ative o App Engine e crie um banco de dados do Datastore. Ativar o App Engine é um pré-requisito para usar o Datastore, mas não usaremos o App Engine para mais nada.
gcloud app create --region=us-central gcloud firestore databases create --type=datastore-mode --region=us-central
Criar uma tela de permissão OAuth
O complemento exige a permissão do usuário para executar e realizar ações nos dados. Configure a tela de consentimento do projeto para ativar essa opção. No codelab, você vai configurar a tela de consentimento como um aplicativo interno, o que significa que ela não pode ser usada para distribuição pública.
- Abra o Console do Google Cloud em uma nova guia ou janela.
- Ao lado de "Console do Google Cloud", Clique na seta para baixo e selecione o projeto.
- No canto superior esquerdo, clique em Menu .
- Clique em APIs e Serviços > Credenciais. A página de credenciais do projeto é exibida.
- Clique na Tela de permissão OAuth. "Tela de permissão OAuth" aparece na tela.
- Em "Tipo de usuário", selecione Interno. Se você usa uma conta @gmail.com, selecione Externo.
- Clique em Criar. Uma opção "Editar registro do app" é exibida.
- Preencha o formulário:
- Em App name, insira "Todo complemento".
- Em E-mail para suporte do usuário, digite seu endereço de e-mail pessoal.
- Em Dados de contato do desenvolvedor, insira seu endereço de e-mail pessoal.
- Clique em Salvar e continuar. O formulário Escopos será exibido.
- No formulário "Escopos", clique em Salvar e continuar. Um resumo será exibido.
- Clique em Voltar ao painel.
4. Criar o complemento inicial
Inicialize o projeto
Para começar, você vai criar um simples "Hello world" e implantá-lo. Complementos são serviços da Web que respondem a solicitações https e respondem com um payload JSON que descreve a interface e as ações a serem tomadas. Neste complemento, você vai usar o Node.js e o framework Express.
Para criar esse modelo de projeto, use o Cloud Shell para criar um novo diretório chamado todo-add-on
e navegue até ele:
mkdir ~/todo-add-on cd ~/todo-add-on
Você fará todo o trabalho para o codelab nesse diretório.
Inicialize o projeto Node.js:
npm init
O NPM faz várias perguntas sobre a configuração do projeto, como nome e versão. Para cada pergunta, pressione ENTER
para aceitar os valores padrão. O ponto de entrada padrão é um arquivo chamado index.js
, que vamos criar a seguir.
Em seguida, instale o Express Framework da Web:
npm install --save express express-async-handler
Criar o back-end do complemento
É hora de começar a criar o app.
Crie um arquivo chamado index.js
. Para criar arquivos, clique no botão Abrir editor na barra de ferramentas da janela do Cloud Shell para usar o Editor do Cloud Shell. Como alternativa, é possível editar e gerenciar arquivos no Cloud Shell usando vim ou emacs.
Depois de criar o arquivo index.js
, adicione o seguinte conteúdo:
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}`)
});
O servidor não faz muito além de mostrar a mensagem "Hello world" e tudo bem. Você vai adicionar mais funcionalidades depois.
Implantar no Cloud Run
Para implantar no Cloud Run, o app precisa ser conteinerizado.
Criar o contêiner
Crie um Dockerfile com o nome Dockerfile
, que contém:
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" ]
Manter arquivos indesejados fora do contêiner
Para ajudar a manter o contêiner leve, crie um arquivo .dockerignore
contendo:
Dockerfile
.dockerignore
node_modules
npm-debug.log
Ativar o Cloud Build
Neste codelab, você vai criar e implantar o complemento várias vezes à medida que novas funcionalidades forem adicionadas. Em vez de executar comandos separados para criar o contêiner, enviá-lo para o registrador do contêiner e implantá-lo no Cloud Build, use o Cloud Build para orquestrar o procedimento. Crie um arquivo cloudbuild.yaml
com instruções sobre como criar e implantar o aplicativo:
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
Execute os comandos abaixo para conceder ao Cloud Build permissão para implantar o app:
PROJECT_ID=$(gcloud config list --format='value(core.project)') PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)') gcloud projects add-iam-policy-binding $PROJECT_ID \ --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \ --role=roles/run.admin gcloud iam service-accounts add-iam-policy-binding \ $PROJECT_NUMBER-compute@developer.gserviceaccount.com \ --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \ --role=roles/iam.serviceAccountUser
Criar e implantar o back-end do complemento
Para iniciar o build, execute este comando no Cloud Shell:
gcloud builds submit
A criação e a implantação completas podem levar alguns minutos para serem concluídas, especialmente na primeira vez.
Quando o build for concluído, verifique se o serviço foi implantado e encontre o URL. Execute o comando:
gcloud run services list --platform managed
Copie esse URL. Você vai precisar dele na próxima etapa. Informe ao Google Workspace como invocar o complemento.
Registrar o complemento
Agora que o servidor está em execução, descreva o complemento para que o Google Workspace saiba como exibi-lo e invocá-lo.
Criar um descritor de implantação
Crie o arquivo deployment.json
com o conteúdo abaixo. Use o URL do app implantado no lugar do marcador 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": {}
}
}
Faça upload do descritor de implantação executando o comando:
gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json
Autorizar o acesso ao back-end do complemento
O framework de complementos também precisa de permissão para chamar o serviço. Execute os comandos abaixo para atualizar a política do IAM do Cloud Run e permitir que o Google Workspace invoque o complemento:
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"
Instalar o complemento para testes
Para instalar o complemento no modo de desenvolvimento na sua conta, execute o seguinte no Cloud Shell:
gcloud workspace-add-ons deployments install todo-add-on
Abra o (Gmail)[https://mail.google.com/] em uma nova guia ou janela. No lado direito, localize o complemento com um ícone de verificação.
Para abrir o complemento, clique no ícone de verificação. Uma solicitação para autorizar o complemento vai aparecer.
Clique em Autorizar acesso e siga as instruções do fluxo de autorização no pop-up. Após a conclusão, o complemento será recarregado automaticamente e exibirá a mensagem "Hello world!" mensagem.
Parabéns! Agora você tem um complemento simples implantado e instalado. É hora de transformá-lo em um aplicativo de lista de tarefas.
5. Acessar a identidade do usuário
Os complementos geralmente são usados por muitos usuários para trabalhar com informações particulares deles ou de suas organizações. Neste codelab, o complemento vai mostrar apenas as tarefas do usuário atual. A identidade do usuário é enviada para o complemento por um token de identidade que precisa ser decodificado.
Adicionar escopos ao descritor de implantação
A identidade do usuário não é enviada por padrão. São dados do usuário, e o complemento precisa de permissão para acessá-los. Para receber essa permissão, atualize o deployment.json
e adicione os escopos do OAuth openid
e email
à lista de escopos do complemento. Depois de adicionar os escopos do OAuth, o complemento solicitará que os usuários concedam acesso na próxima vez que usarem o complemento.
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute",
"openid",
"email"
],
Em seguida, no Cloud Shell, execute este comando para atualizar o descritor de implantação:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Atualizar o servidor de complementos
Embora o complemento esteja configurado para solicitar a identidade do usuário, a implementação ainda precisa ser atualizada.
Analisar o token de identidade
Comece adicionando a biblioteca de autenticação do Google ao projeto:
npm install --save google-auth-library
Em seguida, edite index.js
para exigir OAuth2Client
:
const { OAuth2Client } = require('google-auth-library');
Em seguida, adicione um método auxiliar para analisar o token de ID:
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
Mostrar a identidade do usuário
Esse é um bom momento para fazer um checkpoint antes de adicionar todas as funcionalidades da lista de tarefas. Atualize a rota do app para imprimir o endereço de e-mail e o ID exclusivo do usuário em vez de "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);
}));
Depois dessas mudanças, o arquivo index.js
resultante ficará assim:
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}`)
});
Reimplantar e testar
Recrie e reimplante o complemento. No Cloud Shell, execute:
gcloud builds submit
Assim que o servidor for implantado novamente, abra ou recarregue o Gmail e abra o complemento novamente. Como os escopos mudaram, o complemento vai solicitar uma nova autorização. Autorize o complemento novamente e, após a conclusão, ele vai mostrar seu endereço de e-mail e ID de usuário.
Agora que o complemento sabe quem é o usuário, comece a adicionar a funcionalidade da lista de tarefas.
6. Implementar a lista de tarefas
O modelo de dados inicial do codelab é simples: uma lista de entidades Task
, cada uma com propriedades para o texto descritivo da tarefa e um carimbo de data/hora.
Criar o índice do repositório de dados
O Datastore já foi ativado para o projeto no início do codelab. Ele não requer um esquema, embora exija a criação explícita de índices para consultas compostas. A criação do índice pode levar alguns minutos, então você vai fazer isso primeiro.
Crie um arquivo chamado index.yaml
com o seguinte:
indexes:
- kind: Task
ancestor: yes
properties:
- name: created
Em seguida, atualize os índices do Datastore:
gcloud datastore indexes create index.yaml
Quando for solicitado que você prossiga, pressione ENTER no teclado. A criação do índice ocorre em segundo plano. Enquanto isso, comece a atualizar o código do complemento para implementar "todos".
Atualizar o back-end do complemento
Instale a biblioteca do Datastore no projeto:
npm install --save @google-cloud/datastore
Ler e gravar no Datastore
Atualizar index.js
para implementar "todos" começando com a importação da biblioteca de armazenamento de dados e a criação do cliente:
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
Adicione métodos para ler e gravar tarefas do 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);
}
Implementar a renderização da interface
A maioria das mudanças é na interface do complemento. Antes, todos os cards retornados pela interface eram estáticos e não mudavam dependendo dos dados disponíveis. Aqui, o card precisa ser construído dinamicamente com base na lista de tarefas atual do usuário.
A interface do codelab consiste em uma entrada de texto com uma lista de tarefas com caixas de seleção para marcá-las como concluídas. Cada um deles também tem uma propriedade onChangeAction
que resulta em um callback no servidor de complementos quando o usuário adiciona ou exclui uma tarefa. Em cada um desses casos, a interface precisa ser renderizada novamente com a lista de tarefas atualizada. Para lidar com isso, vamos introduzir um novo método para criar a interface do cartão.
Continue para editar index.js
e adicione o seguinte método:
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;
}
Atualizar as rotas
Agora que existem métodos auxiliares para ler e gravar no Datastore e criar a interface, vamos conectá-los às rotas do app. Substitua a rota atual e adicione mais duas: uma para adicionar tarefas e outra para excluí-las.
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);
}));
Este é o arquivo index.js
final totalmente funcional:
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}`)
});
Reimplantar e testar
Para recriar e reimplantar o complemento, inicie um build. No Cloud Shell, execute:
gcloud builds submit
No Gmail, atualize o complemento e a nova interface será exibida. Reserve um minuto para explorar o complemento. Para adicionar algumas tarefas, insira um texto na entrada e pressione ENTER no teclado. Em seguida, clique na caixa de seleção para excluí-las.
Se quiser, você pode pular para a etapa final deste codelab e limpar seu projeto. Ou, se você quiser continuar aprendendo mais sobre os complementos, há mais uma etapa que pode concluir.
7. (Opcional) Como adicionar contexto
Um dos recursos mais eficientes dos complementos é o reconhecimento de contexto. Com a permissão do usuário, os complementos podem acessar contextos do Google Workspace, como o e-mail que um usuário está vendo, um evento da agenda e um documento. Os complementos também podem realizar ações como inserir conteúdo. Neste codelab, você vai adicionar suporte contextual para que os editores do Workspace (Documentos, Planilhas e Apresentações) anexem o documento atual a qualquer tarefa criada nos editores. Quando a tarefa for exibida, clicar nela abrirá o documento em uma nova guia para que o usuário volte ao documento para concluir a tarefa.
Atualizar o back-end do complemento
Atualizar a rota newTask
Primeiro, atualize a rota /newTask
para incluir o ID do documento em uma tarefa, se ele estiver disponível:
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);
}));
As tarefas recém-criadas agora incluem o ID do documento atual. No entanto, o contexto nos editores não é compartilhado por padrão. Como outros dados do usuário, o usuário precisa conceder permissão para que o complemento acesse os dados. Para evitar o compartilhamento excessivo de informações, a abordagem recomendada é solicitar e conceder permissão por arquivo.
Atualizar a interface
No index.js
, atualize buildCard
para fazer duas mudanças. A primeira é atualizar a renderização das tarefas para incluir um link para o documento, se houver. A segunda é exibir um comando de autorização opcional se o complemento for renderizado em um editor e o acesso ao arquivo ainda não tiver sido concedido.
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;
}
Implementar a rota de autorização de arquivos
O botão de autorização adiciona uma nova rota ao app, então vamos implementá-la. Esta rota introduz um novo conceito: ações do app host. Estas são instruções especiais para interagir com o aplicativo host do complemento. Nesse caso, para solicitar acesso ao arquivo do editor atual.
Em index.js
, adicione a rota /authorizeFile
:
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
Este é o arquivo index.js
final totalmente funcional:
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}`)
});
Adicionar escopos ao descritor de implantação
Antes de recriar o servidor, atualize o descritor de implantação de complementos para incluir o escopo https://www.googleapis.com/auth/drive.file
do OAuth. Atualize o deployment.json
para adicionar https://www.googleapis.com/auth/drive.file
à lista de escopos do 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"
]
Faça o upload da nova versão executando este comando do Cloud Shell:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Reimplantar e testar
Por fim, recrie o servidor. No Cloud Shell, execute:
gcloud builds submit
Após a conclusão, em vez de abrir o Gmail, abra um documento do Google ou crie um novo abrindo doc.new. Se estiver criando um novo documento, digite algum texto ou dê um nome ao arquivo.
Abra o complemento. O complemento mostra o botão Authorize File Access na parte de baixo. Clique no botão e autorize o acesso ao arquivo.
Depois de receber a autorização, adicione uma tarefa no editor. A tarefa tem um rótulo que indica que o documento está anexado. Ao clicar no link, o documento é aberto em uma nova guia. É claro que abrir o documento que você já abriu é um pouco bobagem. Se você quiser otimizar a interface e filtrar links para o documento atual, considere usar esse crédito extra.
8. Parabéns
Parabéns! Você criou e implantou corretamente um complemento do Google Workspace usando o Cloud Run. Embora o codelab tenha abordado muitos dos principais conceitos para a criação de um complemento, há muito mais a explorar. Confira os recursos abaixo e não se esqueça de limpar seu projeto para evitar cobranças adicionais.
Limpar
Para desinstalar o complemento da sua conta, execute este comando no Cloud Shell:
gcloud workspace-add-ons deployments uninstall todo-add-on
Para evitar que os recursos usados nesse tutorial sejam cobrados na sua conta do Google Cloud Platform:
- No console do Cloud, acesse a página Gerenciar recursos: No canto superior esquerdo, clique em Menu > IAM e administrador > Gerenciar recurso.
- Na lista de projetos, selecione seu projeto e clique em Excluir.
- Na caixa de diálogo, digite o ID do projeto e clique em Encerrar para excluí-lo.
Saiba mais
- Visão geral dos complementos do Google Workspace
- Encontre apps e complementos atuais no marketplace