Node.js ve Cloud Run ile Google Workspace eklentisi derleme

1. Giriş

Google Workspace eklentileri, Gmail, Dokümanlar, E-Tablolar ve Slaytlar gibi Google Workspace uygulamalarıyla entegre olan özelleştirilmiş uygulamalardır. Bu API'ler, geliştiricilerin doğrudan Google Workspace'e entegre edilmiş özelleştirilmiş kullanıcı arayüzleri oluşturmasına olanak tanır. Eklentiler, kullanıcıların bağlamlar arasında daha az geçiş yaparak daha verimli çalışmasına yardımcı olur.

Bu codelab'de Node.js, Cloud Run ve Datastore kullanarak basit bir görev listesi eklentisi oluşturmayı ve dağıtmayı öğreneceksiniz.

Neler öğreneceksiniz?

  • Cloud Shell'i kullanma
  • Cloud Run'a dağıt
  • Eklenti dağıtım tanımlayıcısı oluşturma ve dağıtma
  • Kart çerçevesiyle eklenti kullanıcı arayüzleri oluşturma
  • Kullanıcı etkileşimlerine yanıt verme
  • Eklentide kullanıcı bağlamından yararlanma

2. Kurulum ve şartlar

Google Cloud projesi oluşturmak ve eklentinin kullanacağı API'leri ve hizmetleri etkinleştirmek için kurulum talimatlarını uygulayın.

Yönlendirmesiz ortam kurulumu

  1. Cloud Console'u açın ve yeni bir proje oluşturun. (Gmail veya Google Workspace hesabınız yoksa oluşturun.)

Proje seçme menüsü

Yeni Proje düğmesi

Proje kimliği

Proje kimliğini unutmayın. Bu kimlik, tüm Google Cloud projelerinde benzersiz bir addır (Yukarıdaki ad zaten alınmış olduğundan sizin için çalışmayacaktır). Bu codelab'in ilerleyen kısımlarında PROJECT_ID olarak adlandırılacaktır.

  1. Ardından, Google Cloud kaynaklarını kullanmak için Cloud Console'da faturalandırmayı etkinleştirin.

Bu codelab'i tamamlamak neredeyse hiç maliyetli değildir. Bu eğitimin ötesinde faturalandırma ücreti alınmaması için kaynakları nasıl kapatacağınız konusunda size tavsiyelerde bulunan codelab'in sonundaki"Temizleme" bölümündeki talimatları uyguladığınızdan emin olun. Google Cloud'un yeni kullanıcıları 300 ABD doları değerinde ücretsiz deneme programından yararlanabilir.

Google Cloud Shell

Google Cloud, dizüstü bilgisayarınızdan uzaktan çalıştırılabilir. Ancak bu codelab'de, bulutta çalışan bir komut satırı ortamı olan Google Cloud Shell'i kullanacağız.

Cloud Shell'i etkinleştirme

  1. Cloud Console'da Cloud Shell'i etkinleştir 'i Cloud Shell simgesi tıklayın.

Menü çubuğundaki Cloud Shell simgesi

Cloud Shell'i ilk kez açtığınızda açıklayıcı bir karşılama mesajı gösterilir. Karşılama mesajını görürseniz Devam'ı tıklayın. Karşılama mesajı tekrar görünmez. Karşılama mesajı:

Cloud Shell karşılama mesajı

Cloud Shell'in temel hazırlığı ve bağlanması yalnızca birkaç dakikanızı alır. Bağlantı kurulduktan sonra Cloud Shell Terminali'ni görürsünüz:

Cloud Shell Terminali

Bu sanal makine, ihtiyaç duyduğunuz tüm geliştirme araçlarını içerir. 5 GB boyutunda kalıcı bir ana dizin bulunur ve Google Cloud'da çalışır. Bu sayede ağ performansı ve kimlik doğrulama önemli ölçüde güçlenir. Bu codelab'deki tüm çalışmalarınızı tarayıcı veya Chromebook'unuzla yapabilirsiniz.

Cloud Shell'e bağlandıktan sonra kimliğinizin doğrulandığını ve projenin, proje kimliğinize ayarlandığını görürsünüz.

  1. Kimliğinizin doğrulandığını onaylamak için Cloud Shell'de şu komutu çalıştırın:
gcloud auth list

Cloud Shell'in GCP API çağrısı yapması için yetki vermeniz istenirse Yetkilendir'i tıklayın.

Komut çıkışı

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

Etkin hesabı ayarlamak için şu komutu çalıştırın:

gcloud config set account <ACCOUNT>

Doğru projeyi seçtiğinizi onaylamak için Cloud Shell'de şu komutu çalıştırın:

gcloud config list project

Komut çıkışı

[core]
project = <PROJECT_ID>

Doğru proje döndürülmüyorsa şu komutla ayarlayabilirsiniz:

gcloud config set project <PROJECT_ID>

Komut çıkışı

Updated property [core/project].

Bu codelab'de hem komut satırı işlemleri hem de dosya düzenleme kullanılır. Dosya düzenleme için Cloud Shell araç çubuğunun sağ tarafındaki Open Editor (Düzenleyiciyi Aç) düğmesini tıklayarak Cloud Shell'deki yerleşik kod düzenleyiciyi kullanabilirsiniz. Ayrıca, Cloud Shell'de vim ve emacs gibi popüler düzenleyicileri de bulabilirsiniz.

3. Cloud Run, Datastore ve eklenti API'lerini etkinleştirme

Cloud API'lerini etkinleştirme

Cloud Shell'den, kullanılacak bileşenler için Cloud API'lerini etkinleştirin:

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

Bu işlemin tamamlanması birkaç dakika sürebilir.

İşlem tamamlandığında şuna benzer bir başarı mesajı gösterilir:

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

Veri deposu örneği oluşturma

Ardından App Engine'i etkinleştirin ve Datastore veritabanı oluşturun. Datastore'u kullanmak için App Engine'in etkinleştirilmesi gerekir ancak App Engine başka amaçlarla kullanılmaz.

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

Eklentinin çalışması ve veriler üzerinde işlem yapması için kullanıcı izni gerekir. Bunu etkinleştirmek için projenin kullanıcı rızası ekranını yapılandırın. Codelab'de, başlamak için kullanıcı rızası ekranını herkese açık dağıtım için olmayan bir dahili uygulama olarak yapılandıracaksınız.

  1. Google Cloud Console'u yeni bir sekmede veya pencerede açın.
  2. "Google Cloud Console"un yanındaki aşağı oku açılır ok tıklayın ve projenizi seçin.
  3. Sol üst köşedeki Menü simgesini menü simgesi tıklayın.
  4. API'ler ve Hizmetler > Kimlik bilgileri'ni tıklayın. Projenizin kimlik bilgisi sayfası görünür.
  5. OAuth izin ekranı'nı tıklayın. "OAuth kullanıcı rızası ekranı" gösterilir.
  6. "Kullanıcı Türü" bölümünde Dahili'yi seçin. @gmail.com hesabı kullanıyorsanız Harici'yi seçin.
  7. Oluştur'u tıklayın. "Uygulama kaydını düzenle" sayfası görünür.
  8. Formu doldurun:
    • Uygulama adı bölümüne "Todo Add-on" yazın.
    • Kullanıcı destek e-postası bölümüne kişisel e-posta adresinizi girin.
    • Geliştirici iletişim bilgileri bölümünde kişisel e-posta adresinizi girin.
  9. Kaydet ve Devam Et'i tıklayın. Bir Kapsamlar formu gösterilir.
  10. Kapsamlar formunda Kaydet ve Devam Et'i tıklayın. Bir özet gösterilir.
  11. Kontrol paneline dön'ü tıklayın.

4. İlk eklentiyi oluşturma

Projeyi başlatma

Başlamak için basit bir "Hello world" eklentisi oluşturup dağıtacaksınız. Eklentiler, https isteklerine yanıt veren ve kullanıcı arayüzünü ve yapılacak işlemleri açıklayan bir JSON yüküyle yanıt veren web hizmetleridir. Bu eklentide Node.js ve Express çerçevesini kullanacaksınız.

Bu şablon projesini oluşturmak için Cloud Shell'i kullanarak todo-add-on adlı yeni bir dizin oluşturun ve bu dizine gidin:

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

Bu codelab'deki tüm işlemleri bu dizinde yapacaksınız.

Node.js projesini başlatın:

npm init

NPM, proje yapılandırmasıyla ilgili ad ve sürüm gibi çeşitli sorular sorar. Her soru için ENTER tuşuna basarak varsayılan değerleri kabul edin. Varsayılan giriş noktası, bir sonraki adımda oluşturacağımız index.js adlı dosyadır.

Ardından, Express web çerçevesini yükleyin:

npm install --save express express-async-handler

Eklenti arka ucunu oluşturma

Uygulamayı oluşturmaya başlama zamanı.

index.js adlı bir dosya oluşturun. Dosya oluşturmak için Cloud Shell penceresinin araç çubuğundaki Open Editor (Düzenleyiciyi Aç) düğmesini tıklayarak Cloud Shell Düzenleyici'yi kullanabilirsiniz. Alternatif olarak, vim veya emacs kullanarak Cloud Shell'deki dosyaları düzenleyip yönetebilirsiniz.

index.js dosyasını oluşturduktan sonra aşağıdaki içeriği ekleyin:

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

Sunucu, "Merhaba dünya" mesajını göstermek dışında pek bir şey yapmaz ve bu da sorun değildir. Daha sonra başka işlevler ekleyebilirsiniz.

Cloud Run'a dağıt

Cloud Run'a dağıtmak için uygulamanın container mimarisine alınması gerekir.

Kapsayıcıyı oluşturma

Dockerfile adlı bir Dockerfile oluşturun. Bu dosya şunları içermelidir:

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

İstenmeyen dosyaları kapsayıcının dışında tutma

Kapsayıcının hafif kalmasına yardımcı olmak için aşağıdakileri içeren bir .dockerignore dosyası oluşturun:

Dockerfile
.dockerignore
node_modules
npm-debug.log

Cloud Build'i etkinleştirme

Bu codelab'de, yeni işlevler eklendikçe eklentiyi birkaç kez oluşturup dağıtacaksınız. Container'ı oluşturmak, container kayıt defterine aktarmak ve Cloud Build'e dağıtmak için ayrı komutlar çalıştırmak yerine, prosedürü düzenlemek için Cloud Build'i kullanın. Uygulamanın nasıl oluşturulup dağıtılacağıyla ilgili talimatların yer aldığı bir cloudbuild.yaml dosyası oluşturun:

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'e uygulamayı dağıtma izni vermek için aşağıdaki komutları çalıştırın:

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

Eklenti arka ucunu oluşturma ve dağıtma

Derlemeyi başlatmak için Cloud Shell'de şu komutu çalıştırın:

gcloud builds submit

Tam derleme ve dağıtım işleminin tamamlanması birkaç dakika sürebilir. Bu durum özellikle ilk kez yapıldığında geçerlidir.

Derleme tamamlandıktan sonra hizmetin dağıtıldığını doğrulayın ve URL'yi bulun. Komutu çalıştırın:

gcloud run services list --platform managed

Bu URL'yi kopyalayın. Google Workspace'e eklentinin nasıl çağrılacağını bildirmek için bu URL'ye ihtiyacınız olacak.

Eklentiyi kaydetme

Sunucu çalışır duruma geldiğine göre, Google Workspace'in eklentiyi nasıl görüntüleyeceğini ve çağıracağını bilmesi için eklentiyi açıklayın.

Dağıtım tanımlayıcısı oluşturma

Aşağıdaki içeriğe sahip deployment.json dosyasını oluşturun. URL yer tutucusu yerine dağıtılan uygulamanın URL'sini kullandığınızdan emin olun.

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

Şu komutu çalıştırarak dağıtım açıklayıcısını yükleyin:

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

Eklenti arka ucuna erişimi yetkilendirme

Eklenti çerçevesinin de hizmeti çağırmak için izne ihtiyacı vardır. Google Workspace'in eklentiyi çağırmasına izin vermek için Cloud Run'ın IAM politikasını güncellemek üzere aşağıdaki komutları çalıştırın:

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"

Test için eklentiyi yükleme

Eklentiyi hesabınız için geliştirme modunda yüklemek üzere Cloud Shell'de şu komutu çalıştırın:

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

Yeni bir sekmede veya pencerede (Gmail)[https://mail.google.com/] adresini açın. Sağ tarafta, onay işareti simgesi olan eklentiyi bulun.

Yüklü eklenti simgesi

Eklentiyi açmak için onay işareti simgesini tıklayın. Eklentiyi yetkilendirme istemi gösterilir.

Yetkilendirme istemi

Erişimi Yetkilendir'i tıklayın ve açılır penceredeki yetkilendirme akışı talimatlarını uygulayın. Tamamlandığında eklenti otomatik olarak yeniden yüklenir ve "Merhaba dünya!" mesajını gösterir.

Tebrikler! Artık basit bir eklentiniz dağıtılmış ve yüklenmiş durumda. Artık onu bir görev listesi uygulamasına dönüştürme zamanı!

5. Kullanıcı kimliğine erişme

Eklentiler genellikle birçok kullanıcı tarafından kendilerine veya kuruluşlarına ait özel bilgilerle çalışmak için kullanılır. Bu codelab'de, eklenti yalnızca mevcut kullanıcının görevlerini göstermelidir. Kullanıcı kimliği, çözülmesi gereken bir kimlik jetonu aracılığıyla eklentiye gönderilir.

Dağıtım tanımlayıcısına kapsam ekleme

Kullanıcı kimliği varsayılan olarak gönderilmez. Bu, kullanıcı verileridir ve eklentinin bu verilere erişmek için izne ihtiyacı vardır. Bu izni almak için deployment.json öğesini güncelleyin ve eklentinin gerektirdiği kapsamlar listesine openid ve email OAuth kapsamlarını ekleyin. OAuth kapsamları eklendikten sonra, eklenti kullanıcıları bir sonraki kullanımda erişim izni vermeye yönlendirir.

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

Ardından, dağıtım tanımlayıcısını güncellemek için Cloud Shell'de şu komutu çalıştırın:

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

Eklenti sunucusunu güncelleme

Eklenti, kullanıcı kimliğini isteyecek şekilde yapılandırılmış olsa da uygulamanın güncellenmesi gerekir.

Kimlik jetonunu ayrıştırma

Google kimlik doğrulama kitaplığını projeye ekleyerek başlayın:

npm install --save google-auth-library

Ardından, index.js değerini OAuth2Client gerektirecek şekilde düzenleyin:

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

Ardından, kimlik jetonunu ayrıştırmak için bir yardımcı yöntem ekleyin:

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

Kullanıcı kimliğini görüntüleme

Görev listesi işlevlerinin tamamını eklemeden önce kontrol noktası oluşturmak için uygun bir zamandır. Uygulamanın rotasını, "Merhaba dünya" yerine kullanıcının e-posta adresini ve benzersiz kimliğini yazdıracak şekilde güncelleyin.

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

Bu değişikliklerden sonra ortaya çıkan index.js dosyası şu şekilde görünmelidir:

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

Yeniden dağıtma ve test etme

Eklentiyi yeniden oluşturup yeniden dağıtın. Cloud Shell'de şu komutu çalıştırın:

gcloud builds submit

Sunucu yeniden dağıtıldıktan sonra Gmail'i açın veya yeniden yükleyin ve eklentiyi tekrar açın. Kapsamlar değiştiği için eklenti yeniden yetkilendirme isteğinde bulunur. Eklentiyi tekrar yetkilendirin. İşlem tamamlandığında eklenti, e-posta adresinizi ve kullanıcı kimliğinizi gösterir.

Eklenti artık kullanıcının kim olduğunu bildiğine göre görev listesi işlevini eklemeye başlayabilirsiniz.

6. Görev listesini uygulama

Codelab'in ilk veri modeli basittir: Her biri görev açıklayıcı metni ve zaman damgası özelliklerine sahip Task öğelerinin listesi.

Veri deposu dizinini oluşturma

Datastore, codelab'in önceki bölümlerinde proje için etkinleştirilmişti. Şema gerektirmez ancak bileşik sorgular için dizinlerin açıkça oluşturulması gerekir. Dizin oluşturma işlemi birkaç dakika sürebilir. Bu nedenle, önce dizini oluşturmanız gerekir.

Aşağıdaki bilgileri içeren index.yaml adlı bir dosya oluşturun:

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

Ardından Datastore dizinlerini güncelleyin:

gcloud datastore indexes create index.yaml

Devam etmeniz istendiğinde klavyenizde ENTER tuşuna basın. Dizin oluşturma işlemi arka planda gerçekleşir. Bu sırada, "todos"u uygulamak için eklenti kodunu güncellemeye başlayın.

Eklenti arka ucunu güncelleme

Datastore kitaplığını projeye yükleyin:

npm install --save @google-cloud/datastore

Datastore'da okuma ve yazma

Veri deposu kitaplığını içe aktarıp istemciyi oluşturarak başlayarak "todos"u uygulamak için index.js dosyasını güncelleyin:

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

Datastore'dan görev okuma ve yazma yöntemleri ekleyin:

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

Kullanıcı arayüzünün oluşturulmasını uygulama

Değişikliklerin çoğu eklenti kullanıcı arayüzünde yapıldı. Daha önce, kullanıcı arayüzü tarafından döndürülen tüm kartlar statikti ve mevcut verilere göre değişmiyordu. Burada kartın, kullanıcının mevcut görev listesine göre dinamik olarak oluşturulması gerekir.

Codelab'in kullanıcı arayüzü, bir metin girişinin yanı sıra tamamlandı olarak işaretlemek için onay kutularının bulunduğu bir görev listesinden oluşur. Bunların her birinde, kullanıcı bir görev eklediğinde veya sildiğinde eklenti sunucusuna geri çağırma işlemi yapılmasına neden olan bir onChangeAction özelliği de bulunur. Bu durumların her birinde, kullanıcı arayüzünün güncellenmiş görev listesiyle yeniden oluşturulması gerekir. Bunu ele almak için kart kullanıcı arayüzü oluşturmaya yönelik yeni bir yöntem sunalım.

index.js düzenlemeye devam edin ve aşağıdaki yöntemi ekleyin:

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

Rotaları güncelleme

Artık Datastore'a okuma ve yazma işlemleri yapmaya, kullanıcı arayüzü oluşturmaya yardımcı yöntemler olduğuna göre bunları uygulama rotalarında birleştirelim. Mevcut rotayı değiştirin ve iki tane daha ekleyin: biri görev eklemek, diğeri ise görev silmek için.

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

Son ve tamamen işlevsel index.js dosyası aşağıdadır:

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

Yeniden dağıtma ve test etme

Eklentiyi yeniden oluşturup yeniden dağıtmak için derleme başlatın. Cloud Shell'de şunu çalıştırın:

gcloud builds submit

Gmail'de eklentiyi yeniden yüklediğinizde yeni kullanıcı arayüzü görünür. Bir dakikanızı ayırarak eklentiyi inceleyin. Girişe metin girip klavyenizde ENTER tuşuna basarak birkaç görev ekleyin, ardından bunları silmek için onay kutusunu tıklayın.

Görevler içeren eklenti

İsterseniz bu codelab'deki son adıma geçip projenizi temizleyebilirsiniz. Eklentiler hakkında daha fazla bilgi edinmek isterseniz tamamlayabileceğiniz bir adım daha var.

7. (İsteğe bağlı) Bağlam bilgisi ekleme

Eklentilerin en güçlü özelliklerinden biri bağlama duyarlı olmasıdır. Eklentiler, kullanıcı izniyle Google Workspace bağlamlarına (ör. kullanıcının baktığı e-posta, takvim etkinliği ve doküman) erişebilir. Eklentiler, içerik ekleme gibi işlemler de yapabilir. Bu codelab'de, Workspace düzenleyicilerinde (Dokümanlar, E-Tablolar ve Slaytlar) oluşturulan görevlere mevcut dokümanı eklemek için Workspace düzenleyicilerine bağlam desteği ekleyeceksiniz. Görev gösterildiğinde tıklanırsa belge yeni bir sekmede açılır ve kullanıcı görevi tamamlamak için belgeye geri döner.

Eklenti arka ucunu güncelleme

newTask rotasını güncelleme

Öncelikle, /newTask rotasını güncelleyerek varsa doküman kimliğini göreve ekleyin:

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

Yeni oluşturulan görevler artık mevcut doküman kimliğini içeriyor. Ancak, düzenleyicilerdeki bağlam varsayılan olarak paylaşılmaz. Diğer kullanıcı verilerinde olduğu gibi, kullanıcının eklentinin verilere erişmesi için izin vermesi gerekir. Bilgilerin aşırı paylaşılmasını önlemek için tercih edilen yaklaşım, dosya bazında izin istemek ve vermektir.

Kullanıcı arayüzünü güncelleme

index.js bölümünde, iki değişiklik yapmak için buildCard öğesini güncelleyin. İlk olarak, görevlerin oluşturulmasını güncelleyerek varsa dokümana bağlantı ekliyoruz. İkincisi ise eklenti bir düzenleyicide oluşturuluyorsa ve dosya erişimi henüz verilmemişse isteğe bağlı bir yetkilendirme istemi göstermektir.

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

Dosya yetkilendirme rotasını uygulama

Yetkilendirme düğmesi uygulamaya yeni bir rota ekler. Bu nedenle, düğmeyi uygulayalım. Bu rota, yeni bir kavram olan barındırıcı uygulama işlemlerini tanıtır. Bunlar, eklentinin barındırıldığı uygulamayla etkileşim kurmaya yönelik özel talimatlardır. Bu durumda, mevcut düzenleyici dosyasına erişim isteğinde bulunmak için kullanılır.

index.js bölümünde /authorizeFile rotasını ekleyin:

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

Son ve tamamen işlevsel index.js dosyası aşağıdadır:

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

Dağıtım tanımlayıcısına kapsam ekleme

Sunucuyu yeniden oluşturmadan önce, eklenti dağıtım tanımlayıcısını https://www.googleapis.com/auth/drive.file OAuth kapsamını içerecek şekilde güncelleyin. deployment.json öğesini güncelleyerek OAuth kapsamları listesine https://www.googleapis.com/auth/drive.file öğesini ekleyin:

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

Bu Cloud Shell komutunu çalıştırarak yeni sürümü yükleyin:

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

Yeniden dağıtma ve test etme

Son olarak, sunucuyu yeniden oluşturun. Cloud Shell'de şu komutu çalıştırın:

gcloud builds submit

İşlem tamamlandıktan sonra Gmail'i açmak yerine mevcut bir Google dokümanını açın veya doc.new adresini açarak yeni bir doküman oluşturun. Yeni bir doküman oluşturuyorsanız metin girin veya dosyaya ad verin.

Eklentiyi açın. Eklentinin alt kısmında Dosya Erişimine İzin Ver düğmesi gösterilir. Düğmeyi tıklayın, ardından dosyaya erişimi yetkilendirin.

Yetkilendirildikten sonra düzenleyicideyken görev ekleyin. Görevde, dokümanın eklendiğini belirten bir etiket bulunur. Bağlantıyı tıkladığınızda doküman yeni bir sekmede açılır. Elbette, zaten açık olan dokümanı açmak biraz anlamsızdır. Kullanıcı arayüzünü mevcut dokümandaki bağlantıları filtreleyecek şekilde optimize etmek isterseniz bu da ekstra puan kazandırır.

8. Tebrikler

Tebrikler! Cloud Run'ı kullanarak bir Google Workspace eklentisi oluşturup dağıttınız. Bu codelab'de eklenti oluşturmayla ilgili birçok temel kavram ele alınsa da keşfedebileceğiniz daha pek çok şey var. Aşağıdaki kaynaklara göz atın ve ek ücretlerden kaçınmak için projenizi temizlemeyi unutmayın.

Temizleme

Eklentiyi hesabınızdan kaldırmak için Cloud Shell'de şu komutu çalıştırın:

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

Bu eğiticide kullanılan kaynaklar için Google Cloud Platform hesabınızın ücretlendirilmesini istemiyorsanız şunları yapın:

  • Cloud Console'da Kaynakları yönetin sayfasına gidin. Sol üst köşede Menü menü simgesi > IAM ve Yönetici > Kaynakları Yönet'i tıklayın.
  1. Proje listesinde projenizi seçip Sil'i tıklayın.
  2. İletişim kutusunda proje kimliğini yazın ve projeyi silmek için Kapat'ı tıklayın.

Daha fazla bilgi