1. Introduction
Les modules complémentaires Google Workspace sont des applications personnalisées qui s'intègrent aux applications Google Workspace telles que Gmail, Docs, Sheets et Slides. Elles permettent aux développeurs de créer des interfaces utilisateur personnalisées qui sont directement intégrées à Google Workspace. Les modules complémentaires aident les utilisateurs à travailler plus efficacement avec moins de changements de contexte.
Dans cet atelier de programmation, vous allez apprendre à créer et à déployer un module complémentaire de liste de tâches simple à l'aide de Node.js, Cloud Run et Datastore.
Points abordés
- Utiliser Cloud Shell
- Déployer dans Cloud Run
- Créer et déployer un descripteur de déploiement de module complémentaire
- Créer des interfaces utilisateur de modules complémentaires avec le framework de fiche
- Répondre aux interactions des utilisateurs
- Exploiter le contexte utilisateur dans un module complémentaire
2. Préparation
Suivez les instructions de configuration pour créer un projet Google Cloud et activer les API et les services que le module complémentaire utilisera.
Configuration de l'environnement au rythme de chacun
- Ouvrez la console Cloud et créez un projet. (Si vous n'avez pas encore de compte Gmail ou Google Workspace, créez-en un.)
Mémorisez l'ID du projet. Il s'agit d'un nom unique permettant de différencier chaque projet Google Cloud (le nom ci-dessus est déjà pris ; vous devez en trouver un autre). Il sera désigné par le nom PROJECT_ID
tout au long de cet atelier de programmation.
- Ensuite, pour pouvoir utiliser les ressources Google Cloud, activez la facturation dans la console Cloud.
L'exécution de cet atelier de programmation est très peu coûteuse, voire sans frais. Veillez à suivre les instructions de la section "Effectuer un nettoyage" à la fin de l'atelier de programmation, qui vous explique comment arrêter les ressources afin d'éviter toute facturation au-delà de ce tutoriel. Les nouveaux utilisateurs de Google Cloud peuvent participer au programme d'essai sans frais pour bénéficier d'un crédit de 300 $.
Google Cloud Shell
Google Cloud peut être utilisé à distance depuis votre ordinateur portable. Toutefois, dans cet atelier de programmation, nous allons utiliser Google Cloud Shell, un environnement de ligne de commande exécuté dans le cloud.
Activer Cloud Shell
- Dans Cloud Console, cliquez sur Activer Cloud Shell
.
Lorsque vous ouvrez Cloud Shell pour la première fois, un message de bienvenue descriptif s'affiche. Si le message de bienvenue s'affiche, cliquez sur Continuer. Le message de bienvenue n'apparaîtra plus. Voici le message de bienvenue:
Le provisionnement et la connexion à Cloud Shell ne devraient pas prendre plus de quelques minutes. Une fois connecté, le terminal Cloud Shell s'affiche:
Cette machine virtuelle contient tous les outils de développement dont vous avez besoin. Elle comprend un répertoire d'accueil persistant de 5 Go et s'exécute sur Google Cloud, ce qui améliore nettement les performances du réseau et l'authentification. Vous pouvez effectuer tous les travaux de cet atelier de programmation dans un navigateur ou sur votre Chromebook.
Une fois connecté à Cloud Shell, vous êtes en principe authentifié, et le projet est déjà défini avec votre ID de projet.
- Exécutez la commande suivante dans Cloud Shell pour vérifier que vous êtes authentifié :
gcloud auth list
Si vous êtes invité à autoriser Cloud Shell à effectuer un appel d'API GCP, cliquez sur Autoriser.
Résultat de la commande
Credentialed Accounts ACTIVE ACCOUNT * <my_account>@<my_domain.com>
Pour définir le compte actif, exécutez la commande suivante:
gcloud config set account <ACCOUNT>
Pour vérifier que vous avez sélectionné le bon projet, exécutez la commande suivante dans Cloud Shell:
gcloud config list project
Résultat de la commande
[core] project = <PROJECT_ID>
Si vous obtenez un résultat différent, exécutez cette commande :
gcloud config set project <PROJECT_ID>
Résultat de la commande
Updated property [core/project].
L'atelier de programmation combine des opérations de ligne de commande et des modifications de fichiers. Pour modifier des fichiers, vous pouvez utiliser l'éditeur de code intégré dans Cloud Shell en cliquant sur le bouton Ouvrir l'éditeur à droite de la barre d'outils Cloud Shell. D'autres éditeurs populaires tels que Vim et Emacs sont également disponibles dans Cloud Shell.
3. Activer les API Cloud Run, Datastore et les modules complémentaires
Activer API Cloud
Dans Cloud Shell, activez les API Cloud pour les composants qui seront utilisés:
gcloud services enable \ run.googleapis.com \ cloudbuild.googleapis.com \ cloudresourcemanager.googleapis.com \ datastore.googleapis.com \ gsuiteaddons.googleapis.com
Cette opération peut prendre quelques minutes.
Une fois l'opération terminée, un message de confirmation semblable à celui-ci s'affiche :
Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.
Créer une instance de datastore
Ensuite, activez App Engine et créez une base de données Datastore. L'activation d'App Engine est une condition préalable à l'utilisation de Datastore, mais nous n'utiliserons App Engine à aucune autre fin.
gcloud app create --region=us-central gcloud firestore databases create --type=datastore-mode --region=us-central
Créer un écran de consentement OAuth
Le module complémentaire nécessite l'autorisation de l'utilisateur pour s'exécuter et agir sur ses données. Configurez l'écran de consentement du projet pour l'activer. Pour cet atelier de programmation, vous allez configurer l'écran de consentement en tant qu'application interne, c'est-à-dire qu'il n'est pas destiné à être diffusé publiquement.
- Ouvrez la console Google Cloud dans un nouvel onglet ou une nouvelle fenêtre.
- À côté de "Console Google Cloud", Cliquez sur la flèche vers le bas
et sélectionnez votre projet.
- En haut à gauche, cliquez sur Menu
.
- Cliquez sur API et Services > Identifiants La page des identifiants de votre projet s'affiche.
- Cliquez sur l'écran de consentement OAuth. L'écran de consentement OAuth s'affiche.
- Sous "Type d'utilisateur", sélectionnez Interne. Si vous utilisez un compte @gmail.com, sélectionnez Externe.
- Cliquez sur Créer. Une option "Modifier l'enregistrement de l'application" s'affiche.
- Remplissez le formulaire:
- Dans App name (Nom de l'application), saisissez "Todo Add-on".
- Dans Adresse e-mail d'assistance utilisateur, saisissez votre adresse e-mail personnelle.
- Sous Coordonnées du développeur, saisissez votre adresse e-mail personnelle.
- Cliquez sur Enregistrer et continuer. Un formulaire "Champs d'application" s'affiche.
- Dans le formulaire "Scopes" (Champs d'application), cliquez sur Save and Continue (Enregistrer et continuer). Un résumé s'affiche.
- Cliquez sur Retour au tableau de bord.
4. Créer le module complémentaire initial
Initialiser le projet
Pour commencer, vous allez créer un simple "Hello world" et le déployer. Les modules complémentaires sont des services Web qui répondent aux requêtes HTTPS avec une charge utile JSON qui décrit l'interface utilisateur et les actions à entreprendre. Dans ce module complémentaire, vous allez utiliser Node.js et le framework Express.
Pour créer ce modèle de projet, utilisez Cloud Shell afin de créer un répertoire nommé todo-add-on
et d'y accéder:
mkdir ~/todo-add-on cd ~/todo-add-on
Vous effectuerez tout le travail de cet atelier de programmation dans ce répertoire.
Initialisez le projet Node.js :
npm init
GPR pose plusieurs questions sur la configuration du projet, telles que son nom et sa version. Pour chaque question, appuyez sur ENTER
pour accepter les valeurs par défaut. Le point d'entrée par défaut est un fichier nommé index.js
, que nous allons créer ensuite.
Ensuite, installez le framework Web Express:
npm install --save express express-async-handler
Créer le backend du module complémentaire
Il est temps de commencer à créer l'application.
Créez un fichier nommé index.js
. Pour créer des fichiers, vous pouvez utiliser l'éditeur Cloud Shell en cliquant sur le bouton Ouvrir l'éditeur dans la barre d'outils de la fenêtre Cloud Shell. Vous pouvez également modifier et gérer des fichiers dans Cloud Shell à l'aide de Vim ou Emacs.
Après avoir créé le fichier index.js
, ajoutez le contenu suivant:
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}`)
});
Le serveur ne fait pas grand-chose d'autre que d'afficher le message "Hello World" message et ce n'est pas grave. Vous ajouterez des fonctionnalités par la suite.
Déployer dans Cloud Run
Pour être déployée sur Cloud Run, l'application doit être conteneurisée.
Créer le conteneur
Créez un Dockerfile nommé Dockerfile
contenant:
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" ]
Conserver les fichiers indésirables à l'extérieur du conteneur
Pour que le conteneur reste léger, créez un fichier .dockerignore
contenant ce qui suit:
Dockerfile
.dockerignore
node_modules
npm-debug.log
Activer Cloud Build
Dans cet atelier de programmation, vous allez créer et déployer le module complémentaire plusieurs fois à mesure que de nouvelles fonctionnalités seront ajoutées. Au lieu d'exécuter des commandes distinctes pour créer le conteneur, transférez-le dans le registre de conteneurs et déployez-le sur Cloud Build. Utilisez Cloud Build pour orchestrer la procédure. Créez un fichier cloudbuild.yaml
contenant des instructions sur la création et le déploiement de l'application:
steps:
# Build the container image
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME', '.']
# Push the container image to Container Registry
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME']
# Deploy container image to Cloud Run
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args:
- 'run'
- 'deploy'
- '$_SERVICE_NAME'
- '--image'
- 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
- '--region'
- '$_REGION'
- '--platform'
- 'managed'
images:
- 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
substitutions:
_SERVICE_NAME: todo-add-on
_REGION: us-central1
Exécutez les commandes suivantes pour autoriser Cloud Build à déployer l'application:
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
Créer et déployer le backend du module complémentaire
Pour lancer la compilation, exécutez la commande suivante dans Cloud Shell:
gcloud builds submit
La compilation et le déploiement complets peuvent prendre quelques minutes, en particulier la première fois.
Une fois la compilation terminée, vérifiez que le service est déployé et recherchez l'URL. Exécutez la commande suivante :
gcloud run services list --platform managed
Copiez cette URL, car vous en aurez besoin à l'étape suivante, qui consiste à indiquer à Google Workspace comment appeler le module complémentaire.
Enregistrer le module complémentaire
Maintenant que le serveur est opérationnel, décrivez le module complémentaire pour que Google Workspace sache comment l'afficher et l'appeler.
Créer un descripteur de déploiement
Créez le fichier deployment.json
avec le contenu suivant. Veillez à utiliser l'URL de l'application déployée à la place de l'espace réservé 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": {}
}
}
Importez le descripteur de déploiement en exécutant la commande suivante:
gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json
Autoriser l'accès au backend du module complémentaire
Le framework de modules complémentaires doit également être autorisé à appeler le service. Exécutez les commandes suivantes pour mettre à jour la stratégie IAM de Cloud Run afin d'autoriser Google Workspace à appeler le module complémentaire:
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"
Installer le module complémentaire à des fins de test
Pour installer le module complémentaire en mode Développement sur votre compte, exécutez la commande suivante dans Cloud Shell:
gcloud workspace-add-ons deployments install todo-add-on
Ouvrez (Gmail)[https://mail.google.com/] dans un nouvel onglet ou une nouvelle fenêtre. Sur la droite, recherchez le module complémentaire avec une icône en forme de coche.
Pour ouvrir le module complémentaire, cliquez sur l'icône en forme de coche. Un message vous invitant à autoriser le module complémentaire s'affiche.
Cliquez sur Autoriser l'accès et suivez les instructions du flux d'autorisation qui s'affichent dans la fenêtre pop-up. Une fois l'opération terminée, le module complémentaire s'actualise automatiquement et affiche le message "Hello world!" .
Félicitations ! Vous venez de déployer et d'installer un module complémentaire simple. Il est temps d'en faire une application de liste de tâches.
5. Accéder à l'identité de l'utilisateur
Les modules complémentaires sont généralement utilisés par de nombreux utilisateurs pour travailler avec des informations qui sont privées ou privées. Dans cet atelier de programmation, le module complémentaire ne doit afficher que les tâches de l'utilisateur actuel. L'identité de l'utilisateur est envoyée au module complémentaire via un jeton d'identité qui doit être décodé.
Ajouter des niveaux d'accès au descripteur de déploiement
L'identité de l'utilisateur n'est pas envoyée par défaut. Il s'agit des données utilisateur, et le module complémentaire a besoin d'une autorisation pour y accéder. Pour obtenir cette autorisation, mettez à jour deployment.json
, puis ajoutez les habilitations OAuth openid
et email
à la liste des habilitations requises par le module complémentaire. Une fois les habilitations OAuth ajoutées, le module complémentaire invite les utilisateurs à accorder l'accès la prochaine fois qu'ils s'en serviront.
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute",
"openid",
"email"
],
Ensuite, dans Cloud Shell, exécutez la commande suivante pour mettre à jour le descripteur de déploiement:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Mettre à jour le serveur du module complémentaire
Même si le module complémentaire est configuré pour demander l'identité de l'utilisateur, l'implémentation doit encore être mise à jour.
Analyser le jeton d'identité
Commencez par ajouter la bibliothèque d'authentification Google au projet:
npm install --save google-auth-library
Modifiez ensuite index.js
pour exiger OAuth2Client
:
const { OAuth2Client } = require('google-auth-library');
Ajoutez ensuite une méthode d'assistance pour analyser le jeton d'ID:
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
Afficher l'identité de l'utilisateur
C'est le bon moment pour effectuer un point de contrôle avant d'ajouter toutes les fonctionnalités de la liste de tâches. Modifiez la route de l'application pour afficher l'adresse e-mail et l'identifiant unique de l'utilisateur au lieu 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);
}));
Après ces modifications, le fichier index.js
obtenu doit se présenter comme suit:
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}`)
});
Redéployer et tester
Recompilez et redéployez le module complémentaire. Dans Cloud Shell, exécutez la commande suivante:
gcloud builds submit
Une fois le serveur redéployé, ouvrez ou actualisez Gmail, puis rouvrez le module complémentaire. Les champs d'application ayant changé, le module complémentaire demande une nouvelle autorisation. Autorisez à nouveau le module complémentaire. Une fois qu'il est terminé, votre adresse e-mail et votre ID utilisateur s'affichent.
Maintenant que le module complémentaire sait qui est l'utilisateur, vous pouvez commencer à ajouter la fonctionnalité de liste de tâches.
6. Implémenter la liste de tâches
Le modèle de données initial de l'atelier de programmation est simple: il s'agit d'une liste d'entités Task
, chacune avec des propriétés pour le texte descriptif de la tâche et un code temporel.
Créer l'index du datastore
Datastore a déjà été activé pour le projet plus tôt dans l'atelier de programmation. Bien qu'il ne nécessite pas de schéma, il est nécessaire de créer explicitement des index pour les requêtes composées. La création de l'index peut prendre quelques minutes, faites-le en premier.
Créez un fichier nommé index.yaml
avec les éléments suivants:
indexes:
- kind: Task
ancestor: yes
properties:
- name: created
Ensuite, mettez à jour les index Datastore:
gcloud datastore indexes create index.yaml
Lorsque vous êtes invité à continuer, appuyez sur ENTRÉE sur votre clavier. La création de l'index s'effectue en arrière-plan. Pendant ce temps, commencez à mettre à jour le code du module complémentaire pour implémenter les tâches.
Mettre à jour le backend du module complémentaire
Installez la bibliothèque Datastore dans le projet:
npm install --save @google-cloud/datastore
Lire et écrire dans Datastore
Mettez à jour index.js
pour implémenter les tâches. en commençant par importer la bibliothèque Datastore et créer le client:
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
Ajoutez des méthodes pour lire et écrire des tâches à partir de 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);
}
Implémenter le rendu de l'interface utilisateur
La plupart des modifications concernent l'interface utilisateur du module complémentaire. Auparavant, toutes les cartes renvoyées par l'interface utilisateur étaient statiques. Elles n'avaient pas changé en fonction des données disponibles. Ici, la fiche doit être construite de manière dynamique en fonction de la liste de tâches actuelle de l'utilisateur.
L'interface utilisateur de l'atelier de programmation comprend une saisie de texte ainsi qu'une liste de tâches avec des cases à cocher permettant de les marquer comme terminées. Chacun de ces éléments comporte également une propriété onChangeAction
qui entraîne un rappel sur le serveur du module complémentaire lorsque l'utilisateur ajoute ou supprime une tâche. Dans chacun de ces cas, l'interface utilisateur doit être à nouveau affichée avec la liste de tâches mise à jour. Pour gérer cela, introduisons une nouvelle méthode pour créer l'UI de la carte.
Continuez à modifier index.js
et ajoutez la méthode suivante:
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;
}
Mettre à jour les routes
Maintenant qu'il existe des méthodes d'assistance pour lire et écrire dans Datastore, et pour créer l'interface utilisateur, reliez-les dans les routes de l'application. Remplacez la route existante et ajoutez-en deux autres: une pour ajouter des tâches et une pour les supprimer.
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);
}));
Voici le fichier index.js
final entièrement fonctionnel:
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}`)
});
Redéployer et tester
Pour recréer et redéployer le module complémentaire, démarrez une compilation. Dans Cloud Shell, exécutez la commande suivante :
gcloud builds submit
Dans Gmail, actualisez le module complémentaire pour faire apparaître la nouvelle interface utilisateur. Prenez quelques instants pour découvrir le module complémentaire. Ajoutez des tâches en saisissant du texte et en appuyant sur la touche ENTRÉE de votre clavier, puis cochez la case pour les supprimer.
Si vous le souhaitez, vous pouvez passer à la dernière étape de cet atelier de programmation et nettoyer votre projet. Si vous souhaitez en savoir plus sur les modules complémentaires, il vous reste une dernière étape à effectuer.
7. (Facultatif) Ajouter du contexte
L'une des fonctionnalités les plus puissantes des modules complémentaires est la prise en compte du contexte. Avec l'autorisation de l'utilisateur, les modules complémentaires peuvent accéder à des contextes Google Workspace tels que l'e-mail consulté, un événement d'agenda et un document. Les modules complémentaires peuvent également effectuer des actions, comme insérer du contenu. Dans cet atelier de programmation, vous ajouterez une prise en charge contextuelle pour les éditeurs Workspace (Docs, Sheets et Slides) afin de joindre le document actuel à toutes les tâches créées depuis les éditeurs. Lorsque la tâche est affichée, cliquez dessus pour ouvrir le document dans un nouvel onglet afin de rediriger l'utilisateur vers le document pour terminer sa tâche.
Mettre à jour le backend du module complémentaire
Mettre à jour la route newTask
Tout d'abord, mettez à jour la route /newTask
pour inclure l'ID du document dans une tâche, s'il est disponible:
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);
}));
Les tâches nouvellement créées incluent désormais l'ID du document actuel. Toutefois, le contexte fourni par les éditeurs n'est pas partagé par défaut. Comme pour les autres données utilisateur, l'utilisateur doit autoriser le module complémentaire à y accéder. Pour éviter tout partage excessif d'informations, il est préférable de demander et d'accorder l'autorisation pour chaque fichier.
Mettre à jour l'UI
Dans index.js
, mettez à jour buildCard
pour effectuer deux modifications. La première consiste à mettre à jour le rendu des tâches pour inclure un lien vers le document, le cas échéant. La seconde consiste à afficher une invite d'autorisation facultative si le module complémentaire est affiché dans un éditeur et que l'accès au fichier n'est pas encore accordé.
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;
}
Implémenter la route d'autorisation de fichiers
Le bouton d'autorisation ajoute une nouvelle route à l'application. Nous allons donc l'implémenter. Ce parcours introduit un nouveau concept : les actions dans l'application hôte. Il s'agit d'instructions spéciales pour interagir avec l'application hôte du module complémentaire. Dans ce cas, pour demander l'accès au fichier actuel de l'éditeur.
Dans index.js
, ajoutez la route /authorizeFile
:
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
Voici le fichier index.js
final entièrement fonctionnel:
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}`)
});
Ajouter des niveaux d'accès au descripteur de déploiement
Avant de recompiler le serveur, mettez à jour le descripteur de déploiement du module complémentaire pour inclure le champ d'application OAuth de https://www.googleapis.com/auth/drive.file
. Mettez à jour deployment.json
pour ajouter https://www.googleapis.com/auth/drive.file
à la liste des champs d'application 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"
]
Importez la nouvelle version en exécutant la commande Cloud Shell suivante:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Redéployer et tester
Enfin, recompilez le serveur. Dans Cloud Shell, exécutez la commande suivante:
gcloud builds submit
Une fois l'opération terminée, au lieu d'ouvrir Gmail, ouvrez un document Google existant ou créez-en un en ouvrant doc.new. Si vous créez un nouveau document, veillez à saisir du texte ou à donner un nom au fichier.
Ouvrez le module complémentaire. Le module complémentaire affiche un bouton Authorize File Access (Autoriser l'accès aux fichiers) en bas du module complémentaire. Cliquez sur le bouton, puis autorisez l'accès au fichier.
Une fois l'autorisation accordée, ajoutez une tâche depuis l'éditeur. La tâche comporte une étiquette indiquant que le document est joint. Cliquez sur le lien pour ouvrir le document dans un nouvel onglet. Bien sûr, ouvrir un document que vous avez déjà ouvert est un peu absurde. Si vous souhaitez optimiser l'interface utilisateur afin de filtrer les liens vers le document actuel, pensez à ce crédit supplémentaire !
8. Félicitations
Félicitations ! Vous venez de créer et de déployer un module complémentaire Google Workspace à l'aide de Cloud Run. Bien que cet atelier de programmation ait abordé de nombreux concepts fondamentaux de la création d'un module complémentaire, il reste beaucoup à découvrir. Consultez les ressources ci-dessous et n'oubliez pas de nettoyer votre projet pour éviter tous frais supplémentaires.
Effectuer un nettoyage
Pour désinstaller le module complémentaire de votre compte, exécutez la commande suivante dans Cloud Shell:
gcloud workspace-add-ons deployments uninstall todo-add-on
Afin d'éviter la facturation sur votre compte Google Cloud Platform des ressources utilisées dans ce tutoriel, procédez comme suit :
- Dans la console Cloud, accédez à la page Gérer les ressources. En haut à gauche, cliquez sur Menu
> IAM et administration > Gérer les ressources.
- Dans la liste des projets, sélectionnez votre projet, puis cliquez sur Supprimer.
- Dans la boîte de dialogue, saisissez l'ID du projet, puis cliquez sur Arrêter pour supprimer le projet.
En savoir plus
- Présentation des modules complémentaires Google Workspace
- Recherchez les applications et les modules complémentaires existants sur la place de marché.