בניית תוסף ל-Google Workspace באמצעות Node.js ו-Cloud Run

1. מבוא

תוספים ל-Google Workspace הם אפליקציות מותאמות אישית שמשתלבות עם אפליקציות של Google Workspace כמו Gmail , Docs , Sheets ו-Slides. הם מאפשרים למפתחים ליצור ממשקי משתמש מותאמים אישית שמשולבים ישירות ב-Google Workspace. תוספים עוזרים למשתמשים לעבוד בצורה יעילה יותר עם פחות מעבר בין הקשרים.

ב-Codelab הזה תלמדו איך ליצור ולפרוס תוסף פשוט לרשימת משימות באמצעות Node.js, Cloud Run ו-Datastore.

מה תלמדו

  • שימוש ב-Cloud Shell
  • פריסה ב-Cloud Run
  • יצירה ופריסה של מתאר פריסה של תוסף
  • יצירת ממשקי משתמש של תוספים באמצעות מסגרת הכרטיסים
  • להגיב לאינטראקציות של משתמשים
  • שימוש בהקשר של המשתמשים בתוסף

2. הגדרה ודרישות

כדי ליצור פרויקט ב-Google Cloud ולהפעיל את ממשקי ה-API והשירותים שבהם התוסף ישתמש, צריך לבצע את הוראות ההגדרה.

הגדרת סביבה בקצב עצמאי

  1. פותחים את מסוף Cloud ויוצרים פרויקט חדש. (אם אין לכם עדיין חשבון Gmail או Google Workspace, עליכם ליצור חשבון).

התפריט לבחירת פרויקט

לחצן הפרויקט החדש

מזהה הפרויקט

חשוב לזכור את מזהה הפרויקט, שם ייחודי לכל הפרויקטים ב-Google Cloud (השם שלמעלה כבר תפוס ולא מתאים לכם, סליחה). בהמשך ב-Codelab הזה, היא תיקרא PROJECT_ID.

  1. בשלב הבא, כדי להשתמש במשאבים של Google Cloud, צריך להפעיל את החיוב במסוף Cloud.

מעבר ב-Codelab הזה לא אמור לעלות הרבה, אם בכלל. חשוב לבצע את כל ההוראות בקטע 'הסרת המשאבים' בסוף ה-Codelab, שמסביר איך להשבית משאבים כדי שלא תצברו חיובים מעבר למדריך הזה. משתמשים חדשים ב-Google Cloud זכאים להשתתף בתוכנית תקופת ניסיון בחינם בשווי 1,200 ש"ח.

Google Cloud Shell

אומנם אפשר להפעיל את Google Cloud מרחוק מהמחשב הנייד, אבל ב-Codelab הזה נשתמש ב-Google Cloud Shell, סביבת שורת הפקודה שפועלת ב-Cloud.

הפעלת Cloud Shell

  1. במסוף Cloud, לוחצים על Activate Cloud Shell הסמל של Cloud Shell.

הסמל של Cloud Shell בסרגל התפריטים.

בפעם הראשונה שפותחים את Cloud Shell, מוצגת הודעת פתיחה תיאורית. אם מופיעה הודעת הפתיחה, לוחצים על המשך. הודעת הפתיחה לא תופיע שוב. הנה הודעת הפתיחה:

הודעת הפתיחה של Cloud Shell

ההקצאה וההתחברות ל-Cloud Shell נמשכת כמה דקות. אחרי שתתחברו, תראו את הטרמינל של Cloud Shell:

הטרמינל של Cloud Shell

במכונה הווירטואלית הזו משולבת כל כלי הפיתוח שדרושים לכם. יש בה ספריית בית בנפח מתמיד של 5GB והיא פועלת ב-Google Cloud, מה שמשפר משמעותית את ביצועי הרשת והאימות. כל העבודה ב-Codelab הזה יכולה להתבצע באמצעות דפדפן או Chromebook.

אחרי ההתחברות ל-Cloud Shell, אתם אמורים לראות שכבר בוצע אימות ושהפרויקט כבר מוגדר למזהה הפרויקט שלכם.

  1. מריצים את הפקודה הבאה ב-Cloud Shell כדי לוודא שהאימות בוצע:
gcloud auth list

אם מתבקשים לאשר ל-Cloud Shell לבצע קריאה ל-GCP API, לוחצים על Authorize.

פלט הפקודה

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

כדי להגדיר את החשבון הפעיל, מריצים את:

gcloud config set account <ACCOUNT>

כדי לוודא שבחרתם את הפרויקט הנכון, ב-Cloud Shell מריצים את:

gcloud config list project

פלט הפקודה

[core]
project = <PROJECT_ID>

אם לא מוחזר הפרויקט הנכון, אפשר להגדיר אותו באמצעות הפקודה הבאה:

gcloud config set project <PROJECT_ID>

פלט הפקודה

Updated property [core/project].

ב-Codelab נעשה שימוש בשילוב של פעולות בשורת הפקודה וגם של עריכת קבצים. כדי לערוך קבצים, אפשר להשתמש בעורך הקוד המובנה ב-Cloud Shell. כדי לעשות זאת, לוחצים על הלחצן Open Editor בצד שמאל של סרגל הכלים של Cloud Shell. אפשר למצוא גם עורכים פופולריים כמו vim ו-emacs ב-Cloud Shell.

3. הפעלת ממשקי API של Cloud Run, Datastore ותוספים

הפעלת Cloud APIs

ב-Cloud Shell, מפעילים את ממשקי Cloud API לרכיבים שבהם ייעשה שימוש:

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

הפעולה עשויה להימשך כמה דקות.

אחרי שהתהליך יסתיים, תופיע הודעת הצלחה דומה לזו:

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

יצירת מופע של מאגר נתונים

בשלב הבא, מפעילים את App Engine ויוצרים מסד נתונים של Datastore. הפעלת App Engine היא דרישה מוקדמת לשימוש ב-Datastore, אבל לא נשתמש ב-App Engine לשום דבר אחר.

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

כדי שהתוסף יפעל ויבצע פעולות לגבי הנתונים שלו, נדרשת הרשאת משתמש. כדי להפעיל את האפשרות הזו, צריך להגדיר את מסך ההסכמה של הפרויקט. כדי להתחיל ב-Codelab, צריך להגדיר את מסך ההסכמה כאפליקציה פנימית, כלומר הוא לא מיועד להפצה ציבורית.

  1. פותחים את מסוף Google Cloud בכרטיסייה חדשה או בחלון חדש.
  2. לצד 'מסוף Google Cloud', לוחצים על החץ למטה חץ לתפריט נפתח ובוחרים את הפרויקט.
  3. בפינה הימנית העליונה, לוחצים על סמל התפריט סמל התפריט.
  4. לוחצים על APIs & שירותים > פרטי כניסה. יופיע דף פרטי הכניסה של הפרויקט.
  5. לוחצים על מסך ההסכמה של OAuth. מסך ההסכמה של OAuth מופיעה.
  6. בקטע "סוג משתמש", בוחרים באפשרות פנימי. אם אתם משתמשים בחשבון @gmail.com, בוחרים באפשרות חיצוני.
  7. לוחצים על יצירה. הודעה מסוג 'עריכת רישום האפליקציה' מופיעה.
  8. ממלאים את הטופס:
    • בקטע שם האפליקציה, מזינים 'תוסף משימות'.
    • בקטע אימייל לתמיכת משתמשים, מזינים את כתובת האימייל האישית שלכם.
    • בקטע פרטים ליצירת קשר עם המפתח, מזינים את כתובת האימייל האישית.
  9. לוחצים על שמירה והמשך. יופיע טופס 'היקפים'.
  10. בטופס של היקפי ההרשאות, לוחצים על שמירה והמשך. יופיע סיכום.
  11. לוחצים על חזרה למרכז השליטה.

4. יצירת התוסף הראשוני

הפעלת הפרויקט

כדי להתחיל, צריך ליצור דוגמה פשוטה של 'Hello World' ולפרוס אותו. תוספים הם שירותי אינטרנט שמגיבים לבקשות https ומגיבים באמצעות מטען ייעודי (payload) של JSON שמתאר את ממשק המשתמש ואת הפעולות שצריך לבצע. בתוסף הזה משתמשים ב-Node.js וב-framework של Express.

כדי ליצור את פרויקט התבנית הזה, משתמשים ב-Cloud Shell כדי ליצור ספרייה חדשה בשם todo-add-on ולעבור אליה:

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

תבצעו את כל העבודה ב-Codelab בספרייה הזו.

מאתחלים את פרויקט Node.js:

npm init

ב-NPM נשאלות כמה שאלות על תצורת הפרויקט, כמו שם וגרסה. לכל שאלה, צריך להקיש על ENTER כדי לאשר את ערכי ברירת המחדל. נקודת הכניסה בברירת המחדל היא קובץ בשם index.js, ואנחנו ניצור אותו בשלב הבא.

לאחר מכן, מתקינים את מסגרת האינטרנט Express:

npm install --save express express-async-handler

יצירת הקצה העורפי של התוסף

הגיע הזמן להתחיל ליצור את האפליקציה.

יוצרים קובץ בשם index.js. כדי ליצור קבצים, אפשר להשתמש ב-Cloud Shell Editor בלחיצה על הלחצן Open Editor בסרגל הכלים שבחלון של Cloud Shell. לחלופין, אפשר לערוך קבצים ולנהל אותם ב-Cloud Shell באמצעות vim או emacs.

אחרי שיוצרים את הקובץ index.js, מוסיפים את התוכן הבא:

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

השרת לא עושה הרבה חוץ מהצגת 'Hello World' (שלום עולם) וזה בסדר. תהיה לך אפשרות להוסיף עוד פונקציות בהמשך.

פריסה ב-Cloud Run

כדי לפרוס ב-Cloud Run, האפליקציה צריכה להיות בקונטיינרים.

יצירת מאגר התגים

יוצרים Dockerfile בשם Dockerfile שמכיל:

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

יש להרחיק קבצים לא רצויים מהמאגר

כדי לשמור על התאורה בקונטיינר, יוצרים קובץ .dockerignore שמכיל:

Dockerfile
.dockerignore
node_modules
npm-debug.log

הפעלת Cloud Build

ב-Codelab הזה תצרו ותפרסו את התוסף מספר פעמים במקביל להוספה של פונקציונליות חדשה. במקום להריץ פקודות נפרדות כדי לפתח את הקונטיינר, להעביר אותו למאגר הקונטיינרים ולפרוס אותו ב-Cloud Build, אפשר להשתמש ב-Cloud Build כדי לתזמר את התהליך. יוצרים קובץ cloudbuild.yaml עם הוראות ליצירה ולפריסה של האפליקציה:

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

מריצים את הפקודות הבאות כדי לתת ל-Cloud Build הרשאה לפרוס את האפליקציה:

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

יצירה ופריסה של הקצה העורפי של התוסף

כדי להתחיל את ה-build, מריצים ב-Cloud Shell:

gcloud builds submit

תהליך ה-build והפריסה המלאה עשוי להימשך כמה דקות, במיוחד בפעם הראשונה.

בסיום ה-build, מוודאים שהשירות נפרס ומוצאים את כתובת ה-URL. מריצים את הפקודה:

gcloud run services list --platform managed

צריך להעתיק את כתובת ה-URL הזו. יהיה צורך בה בשלב הבא – הוראות ל-Google Workspace איך להפעיל את התוסף.

רישום התוסף

עכשיו, כשהשרת פועל, צריך לתאר את התוסף כדי שמערכת Google Workspace תדע איך להציג ולהפעיל אותו.

יצירת מתאר פריסה

יוצרים את הקובץ deployment.json עם התוכן הבא. יש להקפיד להשתמש בכתובת ה-URL של האפליקציה שנפרסה במקום ב-placeholder של URL.

{
  "oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.execute",
    "https://www.googleapis.com/auth/calendar.addons.execute"
  ],
  "addOns": {
    "common": {
      "name": "Todo Codelab",
      "logoUrl": "https://raw.githubusercontent.com/webdog/octicons-png/main/black/check.png",
      "homepageTrigger": {
        "runFunction": "URL"
      }
    },
    "gmail": {},
    "drive": {},
    "calendar": {},
    "docs": {},
    "sheets": {},
    "slides": {}
  }
}

כדי להעלות את מתאר הפריסה, מריצים את הפקודה:

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

הרשאת גישה לקצה העורפי של התוסף

ל-framework של התוספים נדרשת גם הרשאה כדי לקרוא לשירות. מריצים את הפקודות הבאות כדי לעדכן את מדיניות ה-IAM של Cloud Run, וכך לאפשר ל-Google Workspace להפעיל את התוסף:

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"

איך מתקינים את התוסף לבדיקה

כדי להתקין את התוסף במצב פיתוח לחשבון שלכם ב-Cloud Shell, מריצים את הפקודה:

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

פתיחת (Gmail)[https://mail.google.com/] בכרטיסייה חדשה או בחלון חדש. בצד שמאל, מאתרים את התוסף עם סמל וי.

סמל של תוסף מותקן

כדי לפתוח את התוסף, לוחצים על סימן הווי. תופיע בקשה לאישור התוסף.

בקשה להרשאה

לוחצים על Authorize Access (אישור גישה) ופועלים לפי ההוראות שמוצגות בחלון הקופץ. בסיום התהליך, התוסף נטען מחדש באופן אוטומטי ומציג את ההודעה 'Hello World! ' הודעה.

מעולה! עכשיו יש לך תוסף פשוט שנפרס והותקן. הגיע הזמן להפוך אותו לאפליקציה של רשימת משימות!

5. איך ניגשים לזהות המשתמש

בדרך כלל משתמשים רבים משתמשים בתוספים כדי לעבוד עם מידע שגלוי להם או לארגונים שלהם. ב-Codelab הזה, התוסף אמור להציג רק את המשימות של המשתמש הנוכחי. זהות המשתמש נשלחת לתוסף באמצעות אסימון זהות שצריך לפענח אותו.

הוספת היקפים למתאר הפריסה

זהות המשתמש לא נשלחת כברירת מחדל. מדובר בנתוני משתמשים והתוסף זקוק להרשאה כדי לגשת אליהם. כדי לקבל את ההרשאה הזו, צריך לעדכן את deployment.json ולהוסיף את היקפי ההרשאות של OAuth openid ו-email לרשימת היקפי ההרשאות שהתוסף דורש. אחרי שמוסיפים היקפי הרשאות של OAuth, התוסף מבקש מהמשתמשים להעניק גישה בפעם הבאה שהם משתמשים בתוסף.

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

לאחר מכן, מריצים את הפקודה הבאה ב-Cloud Shell כדי לעדכן את מתאר הפריסה:

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

עדכון השרת של התוסף

התוסף מוגדר לבקש את זהות המשתמש, אבל עדיין צריך לעדכן את ההטמעה.

ניתוח של אסימון הזהות

מתחילים בהוספת ספריית ההרשאות של Google לפרויקט:

npm install --save google-auth-library

לאחר מכן עורכים את index.js כך שידרוש OAuth2Client:

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

לאחר מכן מוסיפים שיטת עזר כדי לנתח את האסימון המזהה:

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

הצגה של זהות המשתמש

זהו זמן טוב לנקודת ביקורת לפני שמוסיפים את כל הפונקציונליות של רשימת המשימות. אפשר לעדכן את נתיב האפליקציה כדי להדפיס את כתובת האימייל והמזהה הייחודי של המשתמש במקום 'Hello World'.

app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const card = {
        sections: [{
            widgets: [
                {
                    textParagraph: {
                        text: `Hello ${user.email} ${user.sub}`
                    }
                },
            ]
        }]
    };
    const renderAction = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(renderAction);
}));

אחרי השינויים האלה, קובץ ה-index.js שמתקבל ייראה כך:

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

פריסה מחדש ובדיקה

יוצרים מחדש ופורסים מחדש את התוסף. ב-Cloud Shell, מריצים את:

gcloud builds submit

לאחר הפריסה מחדש של השרת, פותחים את Gmail או טוענים אותו מחדש ופותחים שוב את התוסף. בגלל שההיקפים השתנו, התוסף יבקש הרשאה מחדש. מאשרים שוב את התוסף, ובסיום התהליך יוצגו כתובת האימייל ומזהה המשתמש שלכם.

עכשיו, שהתוסף יודע מי המשתמש, אפשר להתחיל להוסיף את הפונקציונליות של רשימת המשימות.

6. הטמעת רשימת המשימות

מודל הנתונים הראשוני של ה-Codelab הוא פשוט: רשימה של ישויות Task, שלכל אחת מהן יש מאפיינים עבור הטקסט התיאורי של המשימה וחותמת זמן.

יצירת האינדקס של מאגר הנתונים

Datastore כבר הופעל לפרויקט מוקדם יותר ב-Codelab. לא נדרשת סכימה, אבל כן נדרשת יצירה מפורשת של אינדקסים לשאילתות מורכבות. יצירת האינדקס יכולה להימשך כמה דקות, אז קודם תעשו זאת.

יוצרים קובץ בשם index.yaml עם ההגדרות הבאות:

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

לאחר מכן מעדכנים את האינדקסים של Datastore:

gcloud datastore indexes create index.yaml

כשמוצגת בקשה להמשיך, מקישים על ENTER במקלדת. יצירת האינדקס מתבצעת ברקע. במהלך ביצוע העדכון, התחל לעדכן את קוד התוסף כדי להטמיע את רשימות המשימות.

עדכון הקצה העורפי של התוסף

מתקינים את ספריית Datastore בפרויקט:

npm install --save @google-cloud/datastore

קריאה וכתיבה ב-Datastore

צריך לעדכן את index.js כדי להטמיע את המשימות לביצוע מתחילים בייבוא של ספריית מאגר הנתונים ויצירת הלקוח:

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

מוסיפים שיטות לקריאה ולכתיבה של משימות מ-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);
}

הטמעת רינדור של ממשק המשתמש

רוב השינויים הם בממשק המשתמש של התוסף. מוקדם יותר, כל הכרטיסים שהוחזרו על ידי ממשק המשתמש היו סטטיים – הם לא השתנו בהתאם לנתונים הזמינים. כאן, צריך ליצור את הכרטיס באופן דינמי על סמך רשימת המשימות הנוכחית של המשתמש.

ממשק המשתמש של ה-Codelab מכיל קלט טקסט ורשימת משימות עם תיבות סימון לסימון שהן בוצעו. לכל אחד מהתוספים האלה יש גם מאפיין onChangeAction, שהתוצאה שלו היא קריאה חוזרת (callback) לשרת של התוספים כשהמשתמש מוסיף או מוחק משימה. בכל אחד מהמקרים האלה, צריך לעבד את ממשק המשתמש באמצעות רשימת המשימות המעודכנת. כדי לפתור את הבעיה, נציג שיטה חדשה לפיתוח ממשק המשתמש של הכרטיס.

ממשיכים לערוך את index.js ומוסיפים את השיטה הבאה:

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

עדכון המסלולים

עכשיו, כשיש שיטות מסייעות לקריאה וכתיבה ב-Datastore ולפיתוח ממשק המשתמש, בואו נחבר אותן יחד במסלולים של האפליקציה. מחליפים את המסלול הקיים ומוסיפים שתי אפשרויות: אחת להוספת משימות ואחת למחיקתן.

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

הנה הקובץ 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}`)
});

פריסה מחדש ובדיקה

כדי לבנות מחדש את התוסף ולפרוס אותו מחדש, צריך להתחיל build. ב-Cloud Shell, מריצים את:

gcloud builds submit

ב-Gmail צריך לטעון מחדש את התוסף וממשק המשתמש החדש יופיע. כדאי להקדיש רגע כדי להכיר את התוסף. כדי להוסיף כמה משימות, אפשר להזין טקסט בקלט ולהקיש על ENTER במקלדת, ואז לסמן את התיבה כדי למחוק אותן.

תוסף עם משימות

אם אתם רוצים, תוכלו לדלג קדימה לשלב האחרון ב-Codelab הזה ולנקות את הפרויקט. לחלופין, אם אתם רוצים להמשיך לקבל מידע נוסף על תוספים, יש עוד שלב אחד שאפשר להשלים.

7. (אופציונלי) הוספת הקשר

אחת התכונות הכי חזקות של התוספים היא מוּדעוּת להקשר. תוספים יכולים, בכפוף להרשאת המשתמש, לגשת להקשרים של Google Workspace, כמו אימייל שמשתמש צופה בו, אירוע ביומן ומסמך. תוספים יכולים גם לבצע פעולות כמו הוספת תוכן. ב-Codelab הזה, נוסיף תמיכה בהקשר לעורכי Workspace (Docs, Sheets ו-Slides) כדי לצרף את המסמך הנוכחי לכל המשימות שנוצרו במהלך העבודה עם העורכים. כשהמשימה מוצגת, לחיצה עליה תפתח את המסמך בכרטיסייה חדשה כדי להחזיר את המשתמש למסמך כדי לסיים את המשימה.

עדכון הקצה העורפי של התוסף

עדכון המסלול newTask

קודם צריך לעדכן את הנתיב /newTask כך שיכלול את מזהה המסמך במשימה, אם הוא זמין:

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

מעכשיו, המשימות החדשות שנוצרו כוללות את מזהה המסמך הנוכחי. עם זאת, כברירת מחדל, ההקשר בעורכים לא משותף. בדומה לנתוני משתמש אחרים, המשתמש חייב להעניק לתוסף הרשאה לגשת לנתונים. כדי למנוע שיתוף יתר של מידע, הגישה המועדפת היא לבקש ולהעניק הרשאה לכל קובץ בנפרד.

עדכון ממשק המשתמש

בindex.js צריך לעדכן את buildCard כדי לבצע שני שינויים. השינוי הראשון הוא עדכון העיבוד של המשימות כך שיכללו קישור למסמך, אם יש כזה. השיטה השנייה היא להציג בקשת הרשאה אופציונלית אם התוסף מעובד בכלי עריכה ועדיין לא ניתנה גישה לקובץ.

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

הטמעת הנתיב להרשאה של קבצים

לחצן ההרשאה מוסיף מסלול חדש לאפליקציה, לכן צריך להטמיע אותו. במסלול הזה מוצע קונספט חדש, פעולות שאפשר לבצע באפליקציה המארחת. אלה הוראות מיוחדות לאינטראקציה עם האפליקציה המארחת של התוסף. במקרה כזה, כדי לבקש גישה לקובץ העורך הנוכחי.

בindex.js, מוסיפים את המסלול /authorizeFile:

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

הנה הקובץ 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}`)
});

הוספת היקפים למתאר הפריסה

לפני שבונים מחדש את השרת, צריך לעדכן את מתאר הפריסה של התוסף כך שיכלול את היקף ההרשאות https://www.googleapis.com/auth/drive.file של OAuth. צריך לעדכן את deployment.json כדי להוסיף את https://www.googleapis.com/auth/drive.file לרשימת היקפי ההרשאות של 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"
]

כדי להעלות את הגרסה החדשה, מריצים את הפקודה הבאה של Cloud Shell:

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

פריסה מחדש ובדיקה

לבסוף, בונים מחדש את השרת. ב-Cloud Shell, מריצים את:

gcloud builds submit

בסיום, במקום לפתוח את Gmail, פותחים מסמך קיים של Google או יוצרים מסמך חדש על ידי פתיחת doc.new. כשיוצרים מסמך חדש, חשוב להזין טקסט או לתת שם לקובץ.

פותחים את התוסף. בתוסף מוצג לחצן Authorize File Access בחלק התחתון שלו. לוחצים על הלחצן ומאשרים גישה לקובץ.

אחרי שמקבלים הרשאה, מוסיפים משימה בעורך. למשימה מופיעה תווית שמציינת שהמסמך מצורף. לחיצה על הקישור פותחת את המסמך בכרטיסייה חדשה. כמובן, זה קצת מטופש לפתוח את המסמך שכבר יש לכם. אם אתם רוצים לבצע אופטימיזציה של ממשק המשתמש כך שיסנן קישורים למסמך הנוכחי, כדאי לקחת בחשבון את הקרדיט הנוסף.

8. מזל טוב

מעולה! יצרתם ופרסתם בהצלחה תוסף של Google Workpace באמצעות Cloud Run. אמנם ה-Codelab עוסק ברבים מהעקרונות המרכזיים לבניית תוספים, אבל יש עוד הרבה דברים לגלות. כדאי לעיין במשאבים הבאים ולזכור לנקות את הפרויקט כדי להימנע מחיובים נוספים.

הסרת המשאבים

כדי להסיר את התוסף מהחשבון, מריצים את הפקודה הבאה ב-Cloud Shell:

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

כדי להימנע מצבירת חיובים בחשבון Google Cloud Platform על המשאבים שבהם השתמשתם במדריך הזה:

  • במסוף Cloud, עוברים לדף Manage resources. לוחצים על סמל התפריט סמל התפריט > בפינה הימנית העליונה. IAM ו- אדמין > ניהול משאבים
  1. ברשימת הפרויקטים, בוחרים את הפרויקט הרלוונטי ולוחצים על מחיקה.
  2. כדי למחוק את הפרויקט, כותבים את מזהה הפרויקט בתיבת הדו-שיח ולוחצים על Shut down.

מידע נוסף