Google Workspace-Add-on mit Node.js und Cloud Run erstellen

1. Einleitung

Google Workspace-Add-ons sind benutzerdefinierte Anwendungen, die sich in Google Workspace-Anwendungen wie Gmail, Docs, Tabellen und Präsentationen einbinden lassen. Entwickler können damit benutzerdefinierte Benutzeroberflächen erstellen, die direkt in Google Workspace eingebunden sind. Add-ons helfen Nutzern, effizienter zu arbeiten und müssen weniger zwischen diesen wechseln.

In diesem Codelab erfahren Sie, wie Sie ein einfaches Aufgabenlisten-Add-on mit Node.js, Cloud Run und Datenspeicher erstellen und bereitstellen.

Lerninhalte

  • Cloud Shell verwenden
  • In Cloud Run bereitstellen
  • Add-on-Bereitstellungsdeskriptor erstellen und bereitstellen
  • Add-on-UIs mit dem Karten-Framework erstellen
  • Auf Nutzerinteraktionen reagieren
  • Nutzerkontext in einem Add-on nutzen

2. Einrichtung und Anforderungen

Folgen Sie der Einrichtungsanleitung, um ein Google Cloud-Projekt zu erstellen und die vom Add-on verwendeten APIs und Dienste zu aktivieren.

Umgebung für das selbstbestimmte Lernen einrichten

  1. Öffnen Sie die Cloud Console und erstellen Sie ein neues Projekt. Wenn Sie noch kein Gmail- oder Google Workspace-Konto haben, erstellen Sie eines.

Im Menü „Projekt auswählen“

Die Schaltfläche für ein neues Projekt

Die Projekt-ID

Notieren Sie sich die Projekt-ID, also den projektübergreifend nur einmal vorkommenden Namen eines Google Cloud-Projekts. Der oben angegebene Name ist bereits vergeben und kann leider nicht mehr verwendet werden. Sie wird in diesem Codelab später als PROJECT_ID bezeichnet.

  1. Aktivieren Sie als Nächstes die Abrechnung in der Cloud Console, um Google Cloud-Ressourcen verwenden zu können.

Dieses Codelab sollte ohne großen Aufwand betrieben werden. Folgen Sie der Anleitung im Abschnitt „Bereinigen“ am Ende des Codelab. Darin wird erklärt, wie Sie Ressourcen herunterfahren, um über dieses Tutorial hinaus keine weiteren Kosten zu verursachen. Neue Google Cloud-Nutzer können an der kostenlosen Testversion von 300$ teilnehmen.

Google Cloud Shell

Sie können Google Cloud zwar von Ihrem Laptop aus aus der Ferne bedienen, in diesem Codelab verwenden wir jedoch Google Cloud Shell, eine Befehlszeilenumgebung, die in der Cloud ausgeführt wird.

Cloud Shell aktivieren

  1. Klicken Sie in der Cloud Console auf Cloud Shell aktivieren Das Cloud Shell-Symbol.

Das Cloud Shell-Symbol in der Menüleiste

Wenn Sie Cloud Shell zum ersten Mal öffnen, wird eine beschreibende Willkommensnachricht angezeigt. Wenn Sie die Willkommensnachricht sehen, klicken Sie auf Weiter. Die Willkommensnachricht wird nicht mehr angezeigt. Hier ist die Willkommensnachricht:

Willkommensnachricht von Cloud Shell

Die Bereitstellung und Verbindung mit Cloud Shell dauert nur einen Moment. Nachdem Sie die Verbindung hergestellt haben, wird das Cloud Shell-Terminal angezeigt:

Das Cloud Shell-Terminal

Diese virtuelle Maschine verfügt über alle Entwicklungstools, die Sie benötigen. Es bietet ein Basisverzeichnis mit 5 GB nichtflüchtigem Speicher und wird in Google Cloud ausgeführt. Dadurch werden die Netzwerkleistung und die Authentifizierung erheblich verbessert. Alle Arbeiten in diesem Codelab können mit einem Browser oder Chromebook erledigt werden.

Sobald Sie mit Cloud Shell verbunden sind, sollten Sie sehen, dass Sie bereits authentifiziert sind und dass das Projekt bereits auf Ihre Projekt-ID eingestellt ist.

  1. Führen Sie in Cloud Shell den folgenden Befehl aus, um zu prüfen, ob Sie authentifiziert sind:
gcloud auth list

Wenn Sie aufgefordert werden, Cloud Shell für einen GCP-API-Aufruf zu autorisieren, klicken Sie auf Autorisieren.

Befehlsausgabe

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

Führen Sie folgenden Befehl aus, um das aktive Konto festzulegen:

gcloud config set account <ACCOUNT>

Führen Sie in Cloud Shell folgenden Befehl aus, um zu prüfen, ob Sie das richtige Projekt ausgewählt haben:

gcloud config list project

Befehlsausgabe

[core]
project = <PROJECT_ID>

Wenn nicht das richtige Projekt zurückgegeben wird, können Sie es mit dem folgenden Befehl festlegen:

gcloud config set project <PROJECT_ID>

Befehlsausgabe

Updated property [core/project].

Das Codelab verwendet eine Mischung aus Befehlszeilenvorgängen und Dateibearbeitung. Zum Bearbeiten von Dateien können Sie den integrierten Code-Editor in Cloud Shell verwenden. Klicken Sie dazu rechts in der Cloud Shell-Symbolleiste auf die Schaltfläche Editor öffnen. Außerdem finden Sie in Cloud Shell beliebte Editoren wie Vim und Emacs.

3. Cloud Run, Datastore und Add-on APIs aktivieren

Cloud APIs aktivieren

Aktivieren Sie in Cloud Shell die Cloud APIs für die verwendeten Komponenten:

gcloud services enable \
  run.googleapis.com \
  cloudbuild.googleapis.com \
  cloudresourcemanager.googleapis.com \
  datastore.googleapis.com \
  gsuiteaddons.googleapis.com

Dieser Vorgang kann einen Moment dauern.

Anschließend wird eine Erfolgsmeldung ähnlich der folgenden angezeigt:

Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.

Datenspeicherinstanz erstellen

Aktivieren Sie als Nächstes App Engine und erstellen Sie eine Datenspeicherdatenbank. Die Aktivierung von App Engine ist eine Voraussetzung für die Verwendung von Datastore. App Engine wird jedoch nicht für andere Zwecke verwendet.

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

Das Add-on benötigt die Berechtigung des Nutzers, um seine Daten auszuführen und Aktionen auszuführen. Konfigurieren Sie den Zustimmungsbildschirm des Projekts, um diese Funktion zu aktivieren. Für das Codelab konfigurieren Sie zuerst den Zustimmungsbildschirm als interne Anwendung, d. h., er ist nicht für die öffentliche Bereitstellung bestimmt.

  1. Öffnen Sie die Google Cloud Console in einem neuen Tab oder Fenster.
  2. Klicken Sie neben „Google Cloud Console“ auf den Abwärtspfeil Drop-down-Pfeil und wählen Sie Ihr Projekt aus.
  3. Klicken Sie links oben auf das Dreistrich-Menü Menüsymbol.
  4. Klicken Sie auf APIs und Dienste > Anmeldedaten. Die Seite mit den Anmeldedaten für Ihr Projekt wird angezeigt.
  5. Klicken Sie auf OAuth-Zustimmungsbildschirm. Der Bildschirm „OAuth-Zustimmungsbildschirm“ wird angezeigt.
  6. Wählen Sie unter „Nutzertyp“ die Option Intern aus. Wenn Sie ein @gmail.com-Konto verwenden, wählen Sie Extern aus.
  7. Klicken Sie auf Erstellen. Die Seite „App-Registrierung bearbeiten“ wird angezeigt.
  8. Füllen Sie das Formular aus:
    • Geben Sie unter App-Name „Todo Add-on“ ein.
    • Geben Sie unter Nutzer-Support-E-Mail-Adresse Ihre persönliche E-Mail-Adresse ein.
    • Geben Sie unter Kontaktdaten des Entwicklers Ihre private E-Mail-Adresse ein.
  9. Klicken Sie auf Speichern und fortfahren. Das Formular „Scopes“ wird angezeigt.
  10. Klicken Sie im Formular „Bereiche“ auf Speichern und fortfahren. Eine Zusammenfassung wird angezeigt.
  11. Klicken Sie auf Zurück zum Dashboard.

4. Erstes Add-on erstellen

Projekt initialisieren

Zuerst erstellen Sie ein einfaches „Hello World“-Add-on und stellen es bereit. Add-ons sind Webdienste, die auf HTTPS-Anfragen reagieren und mit einer JSON-Nutzlast antworten, die die Benutzeroberfläche und die auszuführenden Aktionen beschreibt. In diesem Add-on verwenden Sie Node.js und das Express-Framework.

Erstellen Sie zum Erstellen dieses Vorlagenprojekts in Cloud Shell ein neues Verzeichnis mit dem Namen todo-add-on und wechseln Sie zu diesem Verzeichnis:

mkdir ~/todo-add-on
cd ~/todo-add-on

Sie erledigen die gesamte Arbeit für das Codelab in diesem Verzeichnis.

Initialisieren Sie das Node.js-Projekt:

npm init

In NPM werden verschiedene Fragen zur Projektkonfiguration gestellt, z. B. Name und Version. Drücken Sie für jede Frage ENTER, um die Standardwerte zu übernehmen. Der Standardeinstiegspunkt ist eine Datei mit dem Namen index.js, die wir als Nächstes erstellen.

Installieren Sie als Nächstes das Express-Web-Framework:

npm install --save express express-async-handler

Add-on-Back-End erstellen

Jetzt wird die App erstellt.

Erstellen Sie eine Datei mit dem Namen index.js. Zum Erstellen von Dateien können Sie den Cloud Shell-Editor verwenden. Klicken Sie dazu in der Symbolleiste des Cloud Shell-Fensters auf die Schaltfläche Editor öffnen. Alternativ können Sie Dateien mit Vim oder Emacs in Cloud Shell bearbeiten und verwalten.

Fügen Sie nach dem Erstellen der Datei index.js den folgenden Inhalt hinzu:

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}`)
});

Der Server macht nicht viel anderes als die Nachricht „Hello world“. Das ist kein Problem. Weitere Funktionen werden später hinzugefügt.

In Cloud Run bereitstellen

Für die Bereitstellung in Cloud Run muss die Anwendung containerisiert sein.

Container erstellen

Erstellen Sie ein Dockerfile mit dem Namen Dockerfile, das Folgendes enthält:

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

Unerwünschte Dateien außerhalb des Containers aufbewahren

Damit der Container leicht bleibt, erstellen Sie eine .dockerignore-Datei mit folgendem Inhalt:

Dockerfile
.dockerignore
node_modules
npm-debug.log

Cloud Build aktivieren

In diesem Codelab werden Sie das Add-on immer wieder erstellen und bereitstellen, wenn neue Funktionen hinzugefügt werden. Anstatt separate Befehle zum Erstellen des Containers auszuführen, übertragen Sie ihn per Push in die Containerregistrierung und stellen Sie ihn in Cloud Build bereit. Verwenden Sie stattdessen Cloud Build, um das Verfahren zu orchestrieren. Erstellen Sie eine cloudbuild.yaml-Datei mit Anweisungen zum Erstellen und Bereitstellen der Anwendung:

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

Führen Sie die folgenden Befehle aus, um Cloud Build die Berechtigung zum Bereitstellen der Anwendung zu erteilen:

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

Add-on-Back-End erstellen und bereitstellen

Führen Sie in Cloud Shell folgenden Befehl aus, um den Build zu starten:

gcloud builds submit

Der vollständige Build und die Bereitstellung können einige Minuten dauern, insbesondere die erste.

Prüfen Sie nach Abschluss des Builds, ob der Dienst bereitgestellt ist, und ermitteln Sie die URL. Führen Sie diesen Befehl aus:

gcloud run services list --platform managed

Kopieren Sie diese URL. Sie benötigen sie für den nächsten Schritt und weisen Google Workspace an, wie das Add-on aufgerufen werden soll.

Add-on registrieren

Nachdem der Server nun betriebsbereit ist, können Sie das Add-on beschreiben, damit Google Workspace weiß, wie es angezeigt und aufgerufen werden soll.

Bereitstellungsdeskriptor erstellen

Erstellen Sie die Datei deployment.json mit folgendem Inhalt. Achten Sie darauf, die URL der bereitgestellten App anstelle des Platzhalters URL zu verwenden.

{
  "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": {}
  }
}

Laden Sie den Bereitstellungsdeskriptor hoch, indem Sie den folgenden Befehl ausführen:

gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json

Zugriff auf das Add-on-Backend autorisieren

Das Add-on-Framework benötigt außerdem die Berechtigung zum Aufrufen des Dienstes. Führen Sie die folgenden Befehle aus, um die IAM-Richtlinie für Cloud Run zu aktualisieren, damit Google Workspace das Add-on aufrufen kann:

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"

Add-on zum Testen installieren

Führen Sie in Cloud Shell folgenden Befehl aus, um das Add-on im Entwicklungsmodus für Ihr Konto zu installieren:

gcloud workspace-add-ons deployments install todo-add-on

Öffne (Gmail)[https://mail.google.com/] in einem neuen Tab oder Fenster. Suchen Sie auf der rechten Seite nach dem Add-on mit einem Häkchen.

Symbol für installiertes Add-on

Klicken Sie auf das Häkchensymbol, um das Add-on zu öffnen. Sie werden aufgefordert, das Add-on zu autorisieren.

Autorisierungsaufforderung

Klicken Sie auf Zugriff autorisieren und folgen Sie der Anleitung zur Autorisierung. Anschließend wird das Add-on automatisch neu geladen und die Nachricht „Hello world!“ wird angezeigt.

Das wars! Sie haben das Lab erfolgreich abgeschlossen. Sie haben jetzt ein einfaches Add-on bereitgestellt und installiert. Verwandeln Sie sie in eine Aufgabenlistenanwendung.

5. Auf Nutzeridentität zugreifen

Add-ons werden in der Regel von vielen Nutzern verwendet, um mit privaten Informationen zu arbeiten. In diesem Codelab sollte das Add-on nur die Aufgaben des aktuellen Nutzers anzeigen. Die Nutzeridentität wird über ein Identitätstoken an das Add-on gesendet, das decodiert werden muss.

Bereiche zum Bereitstellungsdeskriptor hinzufügen

Die Nutzeridentität wird nicht standardmäßig gesendet. Die Nutzerdaten und das Add-on benötigt Zugriffsberechtigungen. Um diese Berechtigung zu erhalten, aktualisieren Sie deployment.json und fügen Sie die OAuth-Bereiche openid und email der Liste der für das Add-on erforderlichen Bereiche hinzu. Nachdem Sie OAuth-Bereiche hinzugefügt haben, werden die Nutzer vom Add-on aufgefordert, bei der nächsten Verwendung des Add-ons Zugriff zu gewähren.

"oauthScopes": [
      "https://www.googleapis.com/auth/gmail.addons.execute",
      "https://www.googleapis.com/auth/calendar.addons.execute",
      "openid",
      "email"
],

Führen Sie dann in Cloud Shell den folgenden Befehl aus, um den Bereitstellungsdeskriptor zu aktualisieren:

gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json

Add-on-Server aktualisieren

Das Add-on ist zwar so konfiguriert, dass die Nutzeridentität angefordert wird, die Implementierung muss jedoch noch aktualisiert werden.

Identitätstoken parsen

Fügen Sie dem Projekt zuerst die Google Auth-Bibliothek hinzu:

npm install --save google-auth-library

Bearbeiten Sie dann index.js so, dass OAuth2Client erforderlich ist:

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

Fügen Sie dann eine Hilfsmethode hinzu, um das ID-Token zu parsen:

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

Nutzeridentität anzeigen

Dies ist ein guter Zeitpunkt für einen Prüfpunkt, bevor alle Aufgabenlistenfunktionen hinzugefügt werden. Aktualisieren Sie die Route der Anwendung, um die E-Mail-Adresse und die eindeutige ID des Nutzers anstelle von „Hallo Welt“ auszugeben.

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);
}));

Nach diesen Änderungen sollte die resultierende index.js-Datei so aussehen:

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}`)
});

Noch einmal bereitstellen und testen

Erstellen Sie das Add-on neu und stellen Sie es noch einmal bereit. Führen Sie in Cloud Shell folgenden Befehl aus:

gcloud builds submit

Nachdem der Server neu bereitgestellt wurde, öffnen Sie Gmail oder aktualisieren Sie es und öffnen Sie das Add-on noch einmal. Da sich die Bereiche geändert haben, fordert das Add-on eine nochmalige Autorisierung an. Autorisieren Sie das Add-on noch einmal. Anschließend werden Ihre E-Mail-Adresse und Ihre Nutzer-ID angezeigt.

Da das Add-on jetzt weiß, wer der Nutzer ist, können Sie damit beginnen, die Aufgabenlistenfunktion hinzuzufügen.

6. Aufgabenliste implementieren

Das erste Datenmodell für das Codelab ist unkompliziert: eine Liste von Task-Entitäten, die jeweils Attribute für den beschreibenden Text der Aufgabe und einen Zeitstempel haben.

Datenspeicherindex erstellen

Datastore wurde für das Projekt bereits zuvor im Codelab aktiviert. Sie erfordert kein Schema, erfordert jedoch die explizite Erstellung von Indexen für zusammengesetzte Abfragen. Das Erstellen des Index kann einige Minuten dauern.

Erstellen Sie eine Datei mit dem Namen index.yaml, die Folgendes enthält:

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

Aktualisieren Sie dann die Datenspeicherindexe:

gcloud datastore indexes create index.yaml

Wenn Sie aufgefordert werden, fortzufahren, drücken Sie die Eingabetaste auf der Tastatur. Die Indexerstellung erfolgt im Hintergrund. Währenddessen können Sie den Add-on-Code aktualisieren, um die Aufgaben zu implementieren.

Add-on-Backend aktualisieren

Installieren Sie die Datastore-Bibliothek im Projekt:

npm install --save @google-cloud/datastore

Lese- und Schreibzugriff auf Datastore

Aktualisieren Sie index.js, um die Aufgaben zu implementieren, die mit dem Importieren der Datenspeicherbibliothek und dem Erstellen des Clients beginnen:

const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

Fügen Sie Methoden zum Lesen und Schreiben von Aufgaben aus Datastore hinzu:

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);
}

UI-Rendering implementieren

Die meisten Änderungen betreffen die Add-on-Benutzeroberfläche. Zuvor waren alle von der Benutzeroberfläche zurückgegebenen Karten statisch – sie änderten sich nicht abhängig von den verfügbaren Daten. Hier muss die Karte basierend auf der aktuellen Aufgabenliste des Nutzers dynamisch erstellt werden.

Die Benutzeroberfläche für das Codelab besteht aus einer Texteingabe sowie einer Liste von Aufgaben mit Kontrollkästchen, die sie als erledigt markieren. Jedes dieser Elemente hat außerdem ein onChangeAction-Attribut, das zu einem Callback an den Add-on-Server führt, wenn der Nutzer eine Aufgabe hinzufügt oder löscht. In jedem dieser Fälle muss die UI mit der aktualisierten Aufgabenliste neu gerendert werden. Zu diesem Zweck führen wir eine neue Methode zum Erstellen der Karten-UI ein.

Fahren Sie mit der Bearbeitung von index.js fort und fügen Sie die folgende Methode hinzu:

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;
}

Routen aktualisieren

Da es jetzt Hilfsmethoden zum Lesen und Schreiben in Datastore sowie zum Erstellen der Benutzeroberfläche gibt, werden wir diese in den Anwendungsrouten miteinander verbinden. Ersetzen Sie die vorhandene Route und fügen Sie zwei weitere hinzu: eine zum Hinzufügen von Aufgaben und eine zum Löschen von Aufgaben.

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);
}));

Hier ist die endgültige, voll funktionsfähige index.js-Datei:

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}`)
});

Noch einmal bereitstellen und testen

Starten Sie einen Build, um das Add-on neu zu erstellen und neu bereitzustellen. Führen Sie in Cloud Shell folgenden Befehl aus:

gcloud builds submit

Aktualisieren Sie das Add-on in Gmail und die neue UI wird angezeigt. Nehmen Sie sich einen Moment Zeit, um das Add-on kennenzulernen. Sie können Aufgaben hinzufügen, indem Sie Text in die Eingabe eingeben und die Eingabetaste auf Ihrer Tastatur drücken. Klicken Sie dann auf das Kästchen, um die Aufgaben zu löschen.

Add-on mit Aufgaben

Wenn Sie möchten, können Sie zum letzten Schritt in diesem Codelab springen und Ihr Projekt bereinigen. Wenn Sie mehr über Add-ons erfahren möchten, gibt es noch einen Schritt, den Sie ausführen können.

7. Optional: Kontext hinzufügen

Eine der stärksten Funktionen von Add-ons ist die Kontexterkennung. Add-ons können mit Nutzerberechtigung auf Google Workspace-Kontexte zugreifen, z. B. auf die E-Mail, die sich ein Nutzer ansieht, einen Kalendertermin und ein Dokument. Add-ons können auch Aktionen ausführen, z. B. das Einfügen von Inhalten. In diesem Codelab fügen Sie Kontextunterstützung für die Workspace-Editoren (Google Docs, Google Tabellen und Google Präsentationen) hinzu, um das aktuelle Dokument an alle Aufgaben anzuhängen, die in den Editoren erstellt wurden. Wenn die Aufgabe angezeigt wird, wird das Dokument durch Klicken auf einen neuen Tab in einem neuen Tab geöffnet. Der Nutzer kehrt zum Dokument zurück, um seine Aufgabe abzuschließen.

Add-on-Backend aktualisieren

Route newTask aktualisieren

Aktualisieren Sie zuerst die /newTask-Route, um die Dokument-ID in eine Aufgabe aufzunehmen, sofern diese verfügbar ist:

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);
}));

Neu erstellte Aufgaben enthalten jetzt die aktuelle Dokument-ID. Der Kontext wird in den Editoren jedoch nicht standardmäßig freigegeben. Wie bei anderen Nutzerdaten muss der Nutzer dem Add-on die Berechtigung erteilen, auf die Daten zuzugreifen. Um die übermäßige Weitergabe von Informationen zu verhindern, sollten Berechtigungen für einzelne Dateien angefordert und erteilt werden.

Benutzeroberfläche aktualisieren

Aktualisieren Sie buildCard in index.js, um zwei Änderungen vorzunehmen. Die erste ist die Aktualisierung des Renderings der Aufgaben, um einen Link zum Dokument hinzuzufügen, falls vorhanden. Die zweite besteht darin, eine optionale Autorisierungsaufforderung einzublenden, wenn das Add-on in einem Editor gerendert wird und noch kein Dateizugriff gewährt wurde.

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;
}

Route zur Dateiautorisierung implementieren

Über die Autorisierungsschaltfläche wird der App eine neue Route hinzugefügt. Lassen Sie uns diese also implementieren. Mit dieser Route wird ein neues Konzept eingeführt: Host-App-Aktionen. Dies sind spezielle Anweisungen für die Interaktion mit der Hostanwendung des Add-ons. In diesem Fall wird der Zugriff auf die aktuelle Editordatei angefordert.

Fügen Sie in index.js die Route /authorizeFile hinzu:

app.post('/authorizeFile', asyncHandler(async (req, res) => {
    const responsePayload = {
        renderActions: {
            hostAppAction: {
                editorAction: {
                    requestFileScopeForActiveDocument: {}
                }
            },
        }
    };
    res.json(responsePayload);
}));

Hier ist die endgültige, voll funktionsfähige index.js-Datei:

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}`)
});

Bereiche zum Bereitstellungsdeskriptor hinzufügen

Bevor Sie den Server neu erstellen, aktualisieren Sie den Bereitstellungsdeskriptor des Add-ons, um den OAuth-Bereich https://www.googleapis.com/auth/drive.file aufzunehmen. Aktualisieren Sie deployment.json, um https://www.googleapis.com/auth/drive.file der Liste der OAuth-Bereiche hinzuzufügen:

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

Laden Sie die neue Version hoch, indem Sie den folgenden Cloud Shell-Befehl ausführen:

gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json

Noch einmal bereitstellen und testen

Abschließend muss der Server neu erstellt werden. Führen Sie in Cloud Shell folgenden Befehl aus:

gcloud builds submit

Wenn Sie fertig sind, öffnen Sie nicht Gmail, sondern öffnen Sie ein vorhandenes Google-Dokument oder erstellen Sie ein neues, indem Sie doc.new öffnen. Wenn Sie ein neues Dokument erstellen, geben Sie Text ein oder geben Sie der Datei einen Namen.

Öffnen Sie das Add-on. Unten im Add-on wird die Schaltfläche Dateizugriff autorisieren angezeigt. Klicken Sie auf die Schaltfläche und autorisieren Sie den Zugriff auf die Datei.

Nach der Autorisierung können Sie im Editor eine Aufgabe hinzufügen. Die Aufgabe hat ein Label, das darauf hinweist, dass das Dokument angehängt ist. Wenn Sie auf den Link klicken, wird das Dokument in einem neuen Tab geöffnet. Natürlich ist es etwas albern, das bereits geöffnete Dokument zu öffnen. Wenn Sie die Benutzeroberfläche optimieren möchten, um Links für das aktuelle Dokument herauszufiltern, sollten Sie dies zusätzlich berücksichtigen.

8. Glückwunsch

Das wars! Sie haben das Lab erfolgreich abgeschlossen. Sie haben mithilfe von Cloud Run ein Google Workspace-Add-on erstellt und bereitgestellt. Im Codelab wurden viele grundlegende Konzepte zum Erstellen von Add-ons behandelt. Es gibt jedoch noch viel mehr zu entdecken. Sehen Sie sich die folgenden Ressourcen an. Vergessen Sie nicht, Ihr Projekt zu bereinigen, um zusätzliche Kosten zu vermeiden.

Bereinigen

Führen Sie in Cloud Shell den folgenden Befehl aus, um das Add-on von Ihrem Konto zu deinstallieren:

gcloud workspace-add-ons deployments uninstall todo-add-on

So vermeiden Sie, dass Ihrem Google Cloud Platform-Konto die in dieser Anleitung verwendeten Ressourcen berechnet werden:

  • Wechseln Sie in der Cloud Console zur Seite Ressourcen verwalten. Klicken Sie links oben auf MenüMenüsymbol > IAM und Verwaltung > Ressourcen verwalten.
  1. Wählen Sie Ihr Projekt in der Projektliste aus und klicken Sie auf Löschen.
  2. Geben Sie im Dialogfeld die Projekt-ID ein und klicken Sie auf Beenden, um das Projekt zu löschen.

Weitere Informationen