Créer un module complémentaire Google Workspace avec Node.js et Cloud Run

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

  1. 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.)

Menu de sélection d'un projet

Bouton Nouveau projet

ID du projet

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.

  1. 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

  1. Dans Cloud Console, cliquez sur Activer Cloud Shell Icône Cloud Shell.

Icône Cloud Shell dans la barre de menu

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:

Message de bienvenue Cloud Shell

Le provisionnement et la connexion à Cloud Shell ne devraient pas prendre plus de quelques minutes. Une fois connecté, le terminal Cloud Shell s'affiche:

Terminal Cloud Shell

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.

  1. 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

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.

  1. Ouvrez la console Google Cloud dans un nouvel onglet ou une nouvelle fenêtre.
  2. À côté de "Console Google Cloud", Cliquez sur la flèche vers le bas flèche du menu déroulant et sélectionnez votre projet.
  3. En haut à gauche, cliquez sur Menu icône de menu.
  4. Cliquez sur API et Services > Identifiants La page des identifiants de votre projet s'affiche.
  5. Cliquez sur l'écran de consentement OAuth. L'écran de consentement OAuth s'affiche.
  6. Sous "Type d'utilisateur", sélectionnez Interne. Si vous utilisez un compte @gmail.com, sélectionnez Externe.
  7. Cliquez sur Créer. Une option "Modifier l'enregistrement de l'application" s'affiche.
  8. 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.
  9. Cliquez sur Enregistrer et continuer. Un formulaire "Champs d'application" s'affiche.
  10. Dans le formulaire "Scopes" (Champs d'application), cliquez sur Save and Continue (Enregistrer et continuer). Un résumé s'affiche.
  11. 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.

Icône du module complémentaire installé

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.

Invite d&#39;autorisation

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.

Module complémentaire avec des tâches

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 icône de menu > IAM et administration > Gérer les ressources.
  1. Dans la liste des projets, sélectionnez votre projet, puis cliquez sur Supprimer.
  2. 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é.