Tworzenie dodatku do Google Workspace przy użyciu Node.js i Cloud Run

1. Wprowadzenie

Dodatki do Google Workspace to niestandardowe aplikacje, które integrują się z aplikacjami Google Workspace, takimi jak Gmail, Dokumenty, Arkusze i Prezentacje. Umożliwiają one deweloperom tworzenie niestandardowych interfejsów użytkownika, które są bezpośrednio zintegrowane z Google Workspace. Dodatki pomagają użytkownikom pracować wydajniej przy mniejszej rotacji kontekstu.

Z tego ćwiczenia w Codelabs dowiesz się, jak utworzyć i wdrożyć prosty dodatek do listy zadań, używając Node.js, Cloud Run i Datastore.

Czego się nauczysz

  • Korzystanie z Cloud Shell
  • Wdrożenie w Cloud Run
  • Tworzenie i wdrażanie deskryptora wdrożenia dodatku
  • tworzenie interfejsów dodatków za pomocą platformy kart,
  • Reagowanie na interakcje użytkowników
  • Wykorzystywanie kontekstu użytkownika w dodatku

2. Konfiguracja i wymagania

Postępuj zgodnie z instrukcjami konfiguracji, aby utworzyć projekt Google Cloud i włączyć interfejsy API oraz usługi, których będzie używać dodatek.

Samodzielne konfigurowanie środowiska

  1. Otwórz konsolę Cloud i utwórz nowy projekt. Jeśli nie masz jeszcze konta Gmail ani Google Workspace, utwórz je.

Menu wyboru projektu

Przycisk Nowy projekt

Identyfikator projektu

Zapamiętaj identyfikator projektu, unikalną nazwę we wszystkich projektach Google Cloud (powyższa nazwa jest już zajęta i nie będzie Ci odpowiadać). W dalszej części tego ćwiczenia w Codelabs będzie ona określana jako PROJECT_ID.

  1. Aby korzystać z zasobów Google Cloud, włącz płatności w Cloud Console.

Ukończenie tego ćwiczenia z programowania nie powinno kosztować zbyt wiele. Postępuj zgodnie z instrukcjami podanymi w sekcji „Wyczyść” na końcu tego ćwiczenia w Codelabs, gdzie znajdziesz wskazówki dotyczące wyłączania zasobów, aby nie naliczać opłat po zakończeniu tego samouczka. Nowi użytkownicy Google Cloud mogą skorzystać z programu bezpłatnego okresu próbnego o wartości 300 USD.

Google Cloud Shell,

Google Cloud można obsługiwać zdalnie z Twojego laptopa, ale w ramach tego ćwiczenia z programowania wykorzystamy Google Cloud Shell – środowisko wiersza poleceń działające w chmurze.

Aktywowanie Cloud Shell

  1. W konsoli Cloud kliknij Aktywuj Cloud Shell Ikona Cloud Shell.

Ikona Cloud Shell na pasku menu

Przy pierwszym otwarciu Cloud Shell zobaczysz opisową wiadomość powitalną. Gdy zobaczysz wiadomość powitalną, kliknij Dalej. Wiadomość powitalna nie wyświetla się ponownie. Oto wiadomość powitalna:

Wiadomość powitalna Cloud Shell

Uzyskanie dostępu do Cloud Shell i połączenie się z nim powinno zająć tylko kilka chwil. Po nawiązaniu połączenia zobaczysz terminal Cloud Shell:

Terminal Cloud Shell

Ta maszyna wirtualna ma wszystkie potrzebne narzędzia dla programistów. Zawiera stały katalog domowy o pojemności 5 GB i działa w Google Cloud, co znacznie zwiększa wydajność sieci i uwierzytelnianie. Wszystkie zadania w ramach tego ćwiczenia z programowania można wykonywać w przeglądarce lub na Chromebooku.

Po nawiązaniu połączenia z Cloud Shell powinno pojawić się informacja, że użytkownik jest już uwierzytelniony i że projekt jest już ustawiony na identyfikator Twojego projektu.

  1. Uruchom to polecenie w Cloud Shell, aby potwierdzić, że jesteś uwierzytelniony:
gcloud auth list

Jeśli pojawi się prośba o autoryzację Cloud Shell do wywołania interfejsu API GCP, kliknij Autoryzuj.

Dane wyjściowe polecenia

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

Aby ustawić aktywne konto, uruchom polecenie:

gcloud config set account <ACCOUNT>

Aby sprawdzić, czy został wybrany właściwy projekt, w Cloud Shell uruchom polecenie:

gcloud config list project

Dane wyjściowe polecenia

[core]
project = <PROJECT_ID>

Jeśli nie zostanie zwrócony właściwy projekt, możesz go ustawić za pomocą tego polecenia:

gcloud config set project <PROJECT_ID>

Dane wyjściowe polecenia

Updated property [core/project].

Ćwiczenia z programowania wykorzystują połączenie operacji z wiersza poleceń i edytowania plików. Aby edytować pliki, możesz użyć wbudowanego edytora kodu w Cloud Shell. Aby to zrobić, po prawej stronie paska narzędzi Cloud Shell kliknij przycisk Otwórz edytor. W Cloud Shell znajdziesz też popularne edytory, takie jak vim i emacs.

3. Włączanie interfejsów API Cloud Run, Datastore i dodatków

Włącz Cloud APIs

W Cloud Shell włącz interfejsy Cloud APIs dla komponentów, które będą używane:

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

Wykonanie tej operacji może chwilę potrwać.

Po zakończeniu pojawi się komunikat o powodzeniu podobny do tego:

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

Tworzenie instancji magazynu danych

Następnie włącz App Engine i utwórz bazę danych Datastore. Włączenie App Engine jest wymagane do korzystania z Datastore, ale nie będziemy używać App Engine do żadnych innych celów.

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

Dodatek wymaga zgody użytkownika do uruchomienia i podjęcia działania na jego danych. Aby włączyć tę funkcję, skonfiguruj ekran zgody w projekcie. W ramach tych ćwiczeń na początek skonfigurujesz ekran zgody jako aplikację wewnętrzną, czyli nieprzeznaczoną do publicznej dystrybucji.

  1. Otwórz konsolę Google Cloud w nowej karcie lub nowym oknie.
  2. Obok pozycji „Konsola Google Cloud” kliknij strzałkę w dół strzałka w dół i wybierz projekt.
  3. W lewym górnym rogu kliknij menu ikona menu.
  4. Kliknij Interfejsy API i Usługi > Dane logowania. Pojawi się strona z danymi logowania do projektu.
  5. Kliknij Ekran zgody OAuth. „Ekran zgody OAuth” ekranu.
  6. W sekcji „Typ użytkownika” wybierz Wewnętrzny. Jeśli używasz konta @gmail.com, wybierz Zewnętrzne.
  7. Kliknij Utwórz. komunikat „Edytuj rejestrację aplikacji”;
  8. Wypełnij formularz:
    • W polu Nazwa aplikacji wpisz „Dodatek Todo”.
    • W polu Adres e-mail pomocy technicznej dla użytkowników wpisz swój osobisty adres e-mail.
    • W sekcji Dane kontaktowe dewelopera wpisz swój osobisty adres e-mail.
  9. Kliknij Zapisz i kontynuuj. Pojawi się formularz zakresów.
  10. W formularzu zakresów kliknij Save and Continue. Pojawi się podsumowanie.
  11. Kliknij Powrót do panelu.

4. Tworzenie początkowego dodatku

Inicjowanie projektu

Na początek utworzysz prosty „Hello world” i wdrożyć go. Dodatki to usługi internetowe, które odpowiadają na żądania HTTPS i odpowiadają za pomocą ładunku JSON opisującego interfejs użytkownika i działania, jakie należy podjąć. W tym dodatku będziesz używać Node.js i platformy Express.

Aby utworzyć ten projekt szablonu, w Cloud Shell utwórz nowy katalog o nazwie todo-add-on i przejdź do niego:

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

W tym katalogu wykonasz całą pracę związaną z programowaniem.

Zainicjuj projekt Node.js:

npm init

Narzędzie NPM zadaje kilka pytań dotyczących konfiguracji projektu, np. nazwy i wersji. Przy każdym pytaniu naciśnij ENTER, aby zaakceptować wartości domyślne. Domyślnym punktem wejścia jest plik o nazwie index.js, który utworzymy w następnej kolejności.

Następnie zainstaluj platformę internetową Express:

npm install --save express express-async-handler

Tworzenie backendu dodatku

Zacznij tworzyć aplikację.

Utwórz plik o nazwie index.js. Aby utworzyć pliki, możesz użyć edytora Cloud Shell. W tym celu kliknij przycisk Otwórz edytor na pasku narzędzi w oknie Cloud Shell. Możesz też edytować pliki i zarządzać nimi w Cloud Shell za pomocą vim lub emacs.

Po utworzeniu pliku index.js dodaj tę treść:

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

Serwer nie robi nic więcej niż wyświetla komunikat „Hello world”. i nie martw się. Później dodasz więcej funkcji.

Wdrożenie w Cloud Run

Aby można było wdrożyć aplikację w Cloud Run, musi ona być skonteneryzowana.

Tworzenie kontenera

Utwórz Dockerfile o nazwie Dockerfile zawierający:

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

Trzymaj niechciane pliki z dala od kontenera

Aby utrzymać jasność kontenera, utwórz plik .dockerignore zawierający:

Dockerfile
.dockerignore
node_modules
npm-debug.log

Włącz Cloud Build

W ramach tego ćwiczenia w Codelabs dowiesz się, jak kilka razy skompilować i wdrożyć dodatek w miarę dodawania nowych funkcji. Zamiast uruchamiać osobne polecenia do utworzenia kontenera, przenieść go do rejestru kontenerów i wdrożyć w Cloud Build, do administrowania procedurą użyj Cloud Build. Utwórz plik cloudbuild.yaml z instrukcjami kompilowania i wdrażania aplikacji:

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

Uruchom te polecenia, aby przyznać Cloud Build uprawnienia do wdrożenia aplikacji:

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

Tworzenie i wdrażanie backendu dodatku

Aby uruchomić kompilację, w Cloud Shell uruchom polecenie:

gcloud builds submit

Ukończenie pełnej kompilacji i wdrożenia może potrwać kilka minut, zwłaszcza za pierwszym razem.

Po zakończeniu kompilacji sprawdź, czy usługa jest wdrożona i znajdź adres URL. Uruchom polecenie:

gcloud run services list --platform managed

Skopiuj ten URL. Będzie Ci on potrzebny w następnym kroku – informowaniu Google Workspace, jak wywołać dodatek.

Rejestrowanie dodatku

Gdy serwer jest już uruchomiony, opisz dodatek, aby usługa Google Workspace wiedziała, jak go wyświetlić i wywołać.

Tworzenie deskryptora wdrożenia

Utwórz plik deployment.json o następującej zawartości. Pamiętaj, aby zamiast zmiennej URL użyć adresu URL wdrożonej aplikacji.

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

Prześlij deskryptor wdrożenia, uruchamiając polecenie:

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

Autoryzacja dostępu do backendu dodatku

Platforma dodatków wymaga też uprawnień do wywoływania usługi. Uruchom te polecenia, aby zaktualizować zasady uprawnień Cloud Run i umożliwić Google Workspace wywołanie dodatku:

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"

Instalowanie dodatku na potrzeby testowania

Aby zainstalować dodatek w trybie programistycznym na koncie, uruchom w Cloud Shell polecenie:

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

Otwórz (Gmail)[https://mail.google.com/] w nowej karcie lub nowym oknie. Po prawej stronie znajdź dodatek z ikoną znacznika wyboru.

Ikona zainstalowanego dodatku

Aby otworzyć dodatek, kliknij ikonę znacznika wyboru. Pojawi się prośba o autoryzację dodatku.

Prośba o autoryzację

Kliknij Authorize Access (Autoryzuj dostęp) i postępuj zgodnie z instrukcjami autoryzacji w wyskakującym okienku. Gdy to zrobisz, dodatek automatycznie załaduje się ponownie i wyświetli komunikat „Hello world!”, .

Gratulacje! Masz teraz wdrożony i zainstalowany prosty dodatek. Czas zamienić ją w aplikację z listą zadań!

5. Uzyskiwanie dostępu do tożsamości użytkownika

Wielu użytkowników zwykle używa dodatków do pracy z informacjami, które są prywatne dla nich lub ich organizacji. W ramach tych ćwiczeń w Codelabs dodatek powinien wyświetlać zadania tylko dla bieżącego użytkownika. Tożsamość użytkownika jest wysyłana do dodatku za pomocą tokena tożsamości, który musi zostać zdekodowany.

Dodawanie zakresów do deskryptora wdrożenia

Tożsamość użytkownika nie jest domyślnie wysyłana. Dane użytkownika powiązane z kontem są powiązane z kontem, a dodatek potrzebuje do nich odpowiednich uprawnień. Aby uzyskać to uprawnienie, zaktualizuj deployment.json i dodaj zakresy protokołu OAuth openid oraz email do listy zakresów wymaganych przez dodatek. Po dodaniu zakresów protokołu OAuth dodatek poprosi użytkowników o przyznanie dostępu, gdy następnym razem będą go używać.

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

Następnie uruchom w Cloud Shell to polecenie, aby zaktualizować deskryptor wdrożenia:

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

Aktualizowanie serwera dodatków

Mimo że dodatek jest skonfigurowany tak, aby żądał podania tożsamości użytkownika, nadal trzeba zaktualizować implementację.

Analizuj token tożsamości

Najpierw dodaj do projektu bibliotekę uwierzytelniania Google:

npm install --save google-auth-library

Następnie zmodyfikuj zasadę index.js, tak aby wymagana była wartość OAuth2Client:

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

Następnie dodaj metodę pomocniczą, która analizuje token identyfikatora:

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

Wyświetlanie tożsamości użytkownika

Jest to dobry moment na sprawdzenie stanu przed dodaniem wszystkich funkcji listy zadań. Zaktualizuj trasę aplikacji, tak aby zamiast „Hello world” drukował adres e-mail i unikalny identyfikator użytkownika.

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

Po wprowadzeniu tych zmian wynikowy plik index.js powinien wyglądać tak:

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

Wdróż ponownie i przetestuj

Skompiluj i wdróż dodatek ponownie. W Cloud Shell uruchom polecenie:

gcloud builds submit

Po ponownym wdrożeniu serwera otwórz lub załaduj ponownie Gmaila, a następnie jeszcze raz otwórz dodatek. Zakresy się zmieniły, więc dodatek poprosi o ponowną autoryzację. Autoryzuj ponownie dodatek, a gdy dodatek się zakończy, wyświetli się Twój adres e-mail i identyfikator użytkownika.

Dodatek wie, kim jest użytkownik, więc możesz zacząć dodawać funkcję listy zadań.

6. Wdróż listę zadań

Początkowy model danych używany do ćwiczeń z programowania jest prosty: lista elementów Task, z których każda ma właściwości tekstu opisu zadania i sygnatury czasowej.

Tworzenie indeksu magazynu danych

Usługa Datastore została już włączona w projekcie wcześniej w ramach ćwiczeń z programowania. Nie wymaga on jednak schematu, ale wymaga jawnego tworzenia indeksów dla zapytań złożonych. Tworzenie indeksu może potrwać kilka minut, dlatego musisz to zrobić w pierwszej kolejności.

Utwórz plik o nazwie index.yaml z tymi elementami:

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

Następnie zaktualizuj indeksy Datastore:

gcloud datastore indexes create index.yaml

Gdy pojawi się prośba o kontynuowanie, naciśnij ENTER na klawiaturze. Indeks jest tworzony w tle. W tym czasie zacznij aktualizować kod dodatku, aby wdrożyć „zadania do wykonania”.

Aktualizowanie backendu dodatku

Zainstaluj w projekcie bibliotekę Datastore:

npm install --save @google-cloud/datastore

Odczyt i zapis w Datastore

Zaktualizuj index.js, aby zaimplementować listę zadań Zacznij od zaimportowania biblioteki magazynu danych i tworzenia klienta:

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

Dodaj metody odczytu i zapisu zadań w 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);
}

Wdróż renderowanie interfejsu

Większość zmian dotyczy interfejsu dodatku. Wcześniej wszystkie karty zwracane przez interfejs były statyczne i nie zmieniały się w zależności od dostępnych danych. W tym przypadku karta musi być skonstruowana dynamicznie na podstawie bieżącej listy zadań użytkownika.

Interfejs ćwiczeń w Codelabs zawiera pole tekstowe oraz listę zadań z polami wyboru oznaczającymi je jako ukończone. Każda z nich ma też właściwość onChangeAction, która powoduje wywołanie zwrotne do serwera dodatków, gdy użytkownik doda lub usunie zadanie. W każdym z tych przypadków trzeba ponownie wyrenderować interfejs z użyciem zaktualizowanej listy zadań. Aby rozwiązać ten problem, przedstawiamy nową metodę tworzenia interfejsu karty.

Kontynuuj edytowanie metody index.js i dodaj tę metodę:

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

Zaktualizuj trasy

Teraz, gdy dostępne są metody pomocnicze do odczytu i zapisu w Datastore oraz tworzenia interfejsu użytkownika, połączmy je w trasach aplikacji. Zastąp istniejącą trasę i dodaj jeszcze dwie: jedną służącą do dodawania zadań, a drugą służącą do ich usuwania.

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

Oto końcowy, w pełni funkcjonalny plik index.js:

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date()
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    // Input for adding a new task
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        // Create text & checkbox for each task
        tasks.forEach(task => taskListSection.widgets.push({
            decoratedText: {
                text: task.text,
                wrapText: true,
                switchControl: {
                    controlType: 'CHECKBOX',
                    name: 'completedTasks',
                    value: task[datastore.KEY].id,
                    selected: false,
                    onChangeAction: {
                        function: `${baseUrl}/complete`,
                    }
                }
            }
        }));
    } else {
        // Placeholder for empty task list
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    }
    return card;
}

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

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

Wdróż ponownie i przetestuj

Aby ponownie skompilować i wdrożyć dodatek, uruchom kompilację. W Cloud Shell uruchom polecenie:

gcloud builds submit

W Gmailu załaduj ponownie dodatek, a pojawi się nowy interfejs. Poświęć chwilę na zapoznanie się z dodatkiem. Aby dodać kilka zadań, wpisz tekst w polu wprowadzania i naciśnij ENTER na klawiaturze, a następnie kliknij pole wyboru, aby je usunąć.

Dodatek z Listą zadań

Jeśli chcesz, możesz przejść od razu do ostatniego kroku tego ćwiczenia z programowania i wyczyścić projekt. A jeśli chcesz dowiedzieć się więcej o dodatkach, możesz wykonać jeszcze jeden krok.

7. (Opcjonalnie) Dodawanie kontekstu

Jedną z najważniejszych funkcji dodatków jest rozpoznanie kontekstu. Dodatki mogą za zgodą użytkownika uzyskiwać dostęp do kontekstów Google Workspace, takich jak e-mail, z którego korzysta użytkownik, wydarzenie w kalendarzu czy dokument. Dodatki mogą umożliwiać na przykład wstawianie treści. W ramach tego ćwiczenia w programie dodasz pomoc kontekstową dla edytorów Workspace (Dokumentów, Arkuszy i Prezentacji), aby dołączyć bieżący dokument do wszystkich zadań utworzonych w edytorach. Po wyświetleniu zadania kliknięcie go spowoduje otwarcie dokumentu w nowej karcie i skierowanie użytkownika z powrotem do dokumentu, aby dokończyć zadanie.

Aktualizowanie backendu dodatku

Zaktualizuj trasę newTask

Najpierw zaktualizuj trasę /newTask, aby uwzględnić identyfikator dokumentu w zadaniu, jeśli jest dostępny:

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

Nowo utworzone zadania zawierają teraz bieżący identyfikator dokumentu. Domyślnie jednak kontekst w edytorach nie jest udostępniany. Podobnie jak w przypadku innych danych użytkownika, użytkownik musi przyznać dodatkowi uprawnienia dostępu do danych. Aby zapobiec nadmiernemu udostępnianiu informacji, preferowanym sposobem jest proszenie o zgodę i przyznanie uprawnień indywidualnie w przypadku poszczególnych plików.

Aktualizowanie interfejsu użytkownika

W aplikacji index.js zaktualizuj aplikację buildCard, aby wprowadzić 2 zmiany. Pierwszym z nich jest zaktualizowanie renderowania zadań przez dodanie linku do dokumentu, jeśli jest dostępny. Drugim sposobem jest wyświetlanie opcjonalnego komunikatu o autoryzacji, jeśli dodatek jest renderowany w edytorze, a dostęp do pliku nie został jeszcze przyznany.

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

Wdrażanie trasy autoryzacji pliku

Przycisk autoryzacji dodaje do aplikacji nową trasę, więc warto ją wdrożyć. Ta trasa wprowadza nową koncepcję: hostuj działania w aplikacji. To są specjalne instrukcje dotyczące interakcji z aplikacją hosta dodatku. W takim przypadku trzeba poprosić o dostęp do bieżącego pliku edytora.

W aplikacji index.js dodaj trasę /authorizeFile:

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

Oto końcowy, w pełni funkcjonalny plik index.js:

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    // Get the current document if it is present
    const editorInfo = event.docs || event.sheets || event.slides;
    let document = null;
    if (editorInfo && editorInfo.id) {
        document = {
            id: editorInfo.id,
        }
    }
    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date(),
        document,
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

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

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        tasks.forEach(task => {
            const widget = {
                decoratedText: {
                    text: task.text,
                    wrapText: true,
                    switchControl: {
                        controlType: 'CHECKBOX',
                        name: 'completedTasks',
                        value: task[datastore.KEY].id,
                        selected: false,
                        onChangeAction: {
                            function: `${baseUrl}/complete`,
                        }
                    }
                }
            };
            // Make item clickable and open attached doc if present
            if (task.document) {
                widget.decoratedText.bottomLabel = 'Click to open  document.';
                const id = task.document.id;
                const url = `https://drive.google.com/open?id=${id}`
                widget.decoratedText.onClick = {
                    openLink: {
                        openAs: 'FULL_SIZE',
                        onClose: 'NOTHING',
                        url: url,
                    }
                }
            }
            taskListSection.widgets.push(widget)
        });
    } else {
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    };

    // Display file authorization prompt if the host is an editor
    // and no doc ID present
    const event = req.body;
    const editorInfo = event.docs || event.sheets || event.slides;
    const showFileAuth = editorInfo && editorInfo.id === undefined;
    if (showFileAuth) {
        card.fixedFooter = {
            primaryButton: {
                text: 'Authorize file access',
                onClick: {
                    action: {
                        function: `${baseUrl}/authorizeFile`,
                    }
                }
            }
        }
    }
    return card;
}

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

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

Dodawanie zakresów do deskryptora wdrożenia

Zanim odbudujesz serwer, zaktualizuj deskryptor wdrożenia dodatku, tak aby zawierał zakres protokołu OAuth https://www.googleapis.com/auth/drive.file. Zaktualizuj deployment.json, aby dodać zakres https://www.googleapis.com/auth/drive.file do listy zakresów protokołu 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"
]

Prześlij nową wersję, uruchamiając to polecenie Cloud Shell:

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

Wdróż ponownie i przetestuj

Na koniec odbuduj serwer. W Cloud Shell uruchom polecenie:

gcloud builds submit

Po zakończeniu tego procesu zamiast otwierać Gmaila, otwórz istniejący dokument Google lub utwórz nowy, otwierając doc.new. Tworząc nowy dokument, pamiętaj o wpisaniu tekstu lub nadaj plikowi nazwę.

Otwórz dodatek. U dołu dodatku zobaczysz przycisk Autoryzuj dostęp do plików. Kliknij ten przycisk, a następnie autoryzuj dostęp do pliku.

Po autoryzacji dodaj zadanie w edytorze. Zadanie ma etykietę wskazującą, że dokument został załączony. Kliknięcie linku powoduje otwarcie dokumentu w nowej karcie. Otwieranie otwartego dokumentu jest oczywiście trochę głupawe. Jeśli chcesz zoptymalizować interfejs tak, aby odfiltrowywać linki do aktualnego dokumentu, weź pod uwagę ten dodatkowy kredyt.

8. Gratulacje

Gratulacje! Udało Ci się skompilować i wdrożyć dodatek Google Workpace za pomocą Cloud Run. Ćwiczenia z programowania obejmowały wiele podstawowych zagadnień związanych z tworzeniem dodatków, ale to nie wszystko. Zapoznaj się z zasobami poniżej i nie zapomnij wyczyścić projektu, aby uniknąć dodatkowych opłat.

Czyszczenie danych

Aby odinstalować dodatek ze swojego konta, uruchom w Cloud Shell to polecenie:

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

Aby uniknąć obciążenia konta Google Cloud Platform opłatami za zasoby zużyte w tym samouczku:

  • W konsoli Cloud otwórz stronę Zarządzanie zasobami. W lewym górnym rogu kliknij Menu ikona menu. Administracja Administracja > Zarządzanie zasobami.
  1. Na liście projektów wybierz swój projekt i kliknij Usuń.
  2. W oknie wpisz identyfikator projektu i kliknij Wyłącz, aby usunąć projekt.

Więcej informacji