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 edilebilen özelleştirilmiş uygulamalardır. Geliştiricilerin, Google Workspace'e doğrudan entegre edilmiş özelleştirilmiş kullanıcı arayüzleri oluşturmasını sağlarlar. Eklentiler, kullanıcıların daha az bağlam değiştirerek daha verimli bir şekilde çalışmasına yardımcı olur.

Bu codelab'de Node.js, Cloud Run ve Datastore'u kullanarak basit bir görev listesi eklentisini nasıl oluşturacağınızı ve dağıtacağınızı öğ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 yararlanın

2. Kurulum ve şartlar

Kurulum talimatlarını uygulayarak bir Google Cloud projesi oluşturun ve eklentinin kullanacağı API'leri ve hizmetleri etkinleştirin.

Kendi hızınızda ortam kurulumu

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

Proje seçin menüsü

Yeni Proje düğmesi

Proje Kimliği

Tüm Google Cloud projelerinde benzersiz bir ad olan proje kimliğini unutmayın (yukarıdaki ad zaten alınmış ve size uygun olmayacaktır!). Bu kod laboratuvarın ilerleyen bölümlerinde 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 çalıştırmanın maliyeti, yüksek değildir. Bu eğiticinin dışında faturalandırmayla karşılaşmamak için kaynakları nasıl kapatacağınız konusunda size yol gösteren, codelab'in sonundaki"Temizleme" bölümünde yer alan tüm talimatları uygulamayı unutmayın. Yeni Google Cloud kullanıcıları 300 ABD doları ücretsiz deneme programından yararlanabilir.

Google Cloud Shell

Google Cloud dizüstü bilgisayarınızdan uzaktan çalıştırılabilse de bu codelab'de, Cloud'da ç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 Cloud Shell simgesi simgesini tıklayın.

Menü çubuğundaki Cloud Shell simgesi

Cloud Shell'i ilk açtığınızda açıklayıcı bir karşılama mesajıyla karşılaşırsınız. Karşılama mesajını görürseniz Devam'ı tıklayın. Karşılama mesajı tekrar gösterilmez. Karşılama mesajı şöyle:

Cloud Shell karşılama mesajı

Temel hazırlık ve Cloud Shell'e bağlanmak yalnızca birkaç dakika sürer. Bağlandıktan sonra Cloud Shell Terminali gösterilir:

Cloud Shell Terminali

İhtiyaç duyduğunuz tüm geliştirme araçları bu sanal makinede yüklüdür. 5 GB boyutunda kalıcı bir ana dizin sunar ve Google Cloud'da çalışarak ağ performansını ve kimlik doğrulamasını büyük ölçüde iyileştirir. Bu codelab'deki tüm çalışmalarınızı tarayıcı veya Chromebook'unuzla gerçekleştirebilirsiniz.

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. Kimlik doğrulamanızın tamamlandığını onaylamak için Cloud Shell'de aşağıdaki komutu çalıştırın:
gcloud auth list

Cloud Shell'e GCP API çağrısı yapma yetkisi 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ülmezse şu komutla projeyi ayarlayabilirsiniz:

gcloud config set project <PROJECT_ID>

Komut çıkışı

Updated property [core/project].

Codelab'de, komut satırı işlemlerinin yanı sıra dosya düzenleme yöntemi de kullanılır. Dosya düzenleme için Cloud Shell araç çubuğunun sağ tarafındaki Düzenleyiciyi Aç düğmesini tıklayarak Cloud Shell'deki yerleşik kod düzenleyiciyi kullanabilirsiniz. Cloud Shell'de vim ve emacs gibi popüler düzenleyiciler de bulabilirsiniz.

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

Cloud APIs'i etkinleştir

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örüntülenir:

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'i etkinleştirmek bir ön koşuldur. Ancak App Engine'i başka hiçbir amaçla kullanmayız.

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

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

  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öşeden Menü'yü menü simgesi tıklayın.
  4. API'ler ve Hizmetler > Kimlik bilgileri'ni tıklayın. Projenizin kimlik bilgisi sayfası görüntülenir.
  5. OAuth izin ekranı'nı tıklayın. "OAuth izin ekranı" ekranı görünür.
  6. "Kullanıcı Türü" başlığı altında Dahili'yi seçin. Bir @gmail.com hesabı kullanıyorsanız Harici'yi seçin.
  7. Oluştur'u tıklayın. "Uygulama kaydını düzenleme" sayfası görüntülenir.
  8. Formu doldurun:
    • Uygulama adı alanına "Yapılacaklar Listesi Eklentisi" yazın.
    • Kullanıcı desteği e-postası alanına kişisel e-posta adresinizi girin.
    • Geliştirici iletişim bilgileri bölümünün altına kişisel e-posta adresinizi girin.
  9. Kaydet ve Devam Et'i tıklayın. Bir Kapsamlar formu görüntülenir.
  10. Kapsamlar formunda Kaydet ve Devam Et'i tıklayın. Bir özet görüntülenir.
  11. Kontrol Paneline Dön'ü tıklayın.

4. İlk eklentiyi oluşturma

Projeyi ilk kullanıma hazırlama

Başlamak için basit bir "Hello world" eklentisi oluşturup dağıtın. 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 adında yeni bir dizin oluşturun ve bu dizine gidin:

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

Codelab ile ilgili 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 varsayılan değerleri kabul etmek üzere ENTER tuşuna basın. Varsayılan giriş noktası, sonraki adımda oluşturacağımız index.js adlı bir 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şlamanın zamanı geldi.

index.js adlı bir dosya oluşturun. Dosya oluşturmak için Cloud Shell penceresinin araç çubuğundaki Düzenleyiciyi Aç düğmesini tıklayarak Cloud Shell Düzenleyici'yi kullanabilirsiniz. Alternatif olarak vim veya emacs kullanarak Cloud Shell'deki dosyaları düzenleyebilir ve 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, "Hello world" mesajını göstermekten başka bir şey yapmaz; sorun değil. Daha sonra başka işlevler ekleyeceksiniz.

Cloud Run'a dağıt

Cloud Run'da dağıtmak için uygulamanın container mimarisine alınmış olması gerekir.

Kapsayıcıyı oluşturma

Aşağıdakileri içeren Dockerfile adlı bir Dockerfile oluşturun:

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 tutun

Kapsayıcının ışık tutmaya yardımcı olması için şunları içeren bir .dockerignore dosyası oluşturun:

Dockerfile
.dockerignore
node_modules
npm-debug.log

Cloud Build'i etkinleştir

Bu codelab'de, yeni işlevler eklendikçe eklentiyi birkaç kez derleyip dağıtacaksınız. Container'ı derlemek için ayrı komutlar çalıştırmak, container kaydına aktarmak ve Cloud Build'e dağıtmak yerine Cloud Build'i kullanarak prosedürü düzenleyin. Uygulamanın nasıl derleneceği ve dağıtılacağı ile ilgili talimatları içeren 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ı, özellikle ilk seferde birkaç dakika sürebilir.

Derleme tamamlandığında hizmetin dağıtıldığını doğrulayın ve URL'yi bulun. Şu komutu çalıştırın:

gcloud run services list --platform managed

Bu URL'yi kopyalayın. Bir sonraki adımda (Google Workspace'e eklentiyi nasıl çağıracağını bildirme) ihtiyacınız olacak.

Eklentiyi kaydedin

Sunucu çalışır durumda olduğuna 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 açıklayıcısı oluşturma

Aşağıdaki içerikle 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şim yetkisi verme

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

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ükleyin

Hesabınız için eklentiyi 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/] sayfasını 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 yetkilendirmek için bir istem gösterilir.

Yetkilendirme istemi

Erişimi Yetkilendir'i tıklayın ve pop-up'taki yetkilendirme akışı talimatlarını uygulayın. İşlem tamamlandığında eklenti otomatik olarak yeniden yüklenir ve "Hello world!" mesajı gösterilir.

Tebrikler! Artık dağıtılıp yüklenmiş basit bir eklentiniz var. Şimdi 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 özel bilgilerle çalışmak için kullanılır. Bu codelab'de eklenti yalnızca geçerli kullanıcının görevlerini göstermelidir. Kullanıcı kimliği, kodunun çözülmesi gereken bir kimlik jetonu aracılığıyla eklentiye gönderilir.

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

Kullanıcı kimliği varsayılan olarak gönderilmez. Bu veriler kullanıcı verilerini içerir ve eklentinin bu verilere erişmek için izne ihtiyacı vardır. Bu izni elde etmek için deployment.json uygulamasını güncelleyin ve openid ve email OAuth kapsamlarını, eklentinin gerektirdiği kapsamlar listesine ekleyin. OAuth kapsamları eklendikten sonra, eklenti, eklentiyi bir sonraki kullanışlarında kullanıcılardan erişim izni ister.

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

Ardından, Cloud Shell'de dağıtım açıklayıcıyı güncellemek için ş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ır

İlk olarak Google kimlik doğrulama kitaplığını projeye ekleyin:

npm install --save google-auth-library

Ardından OAuth2Client yönergesini zorunlu kılacak şekilde index.js öğesini 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öster

Tüm görev listesi işlevlerini eklemeden önce kontrol noktası belirlemek için iyi bir zamandır. "Merhaba dünya" yerine kullanıcının e-posta adresini ve benzersiz kimliğini yazdırmak için uygulamanın yolunu 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 elde edilen index.js dosyası aşağıdaki gibi 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ğıt ve test et

Eklentiyi yeniden derleyip yeniden dağıtın. Cloud Shell'den ş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ğinden eklenti yeniden yetkilendirme ister. Eklentiyi tekrar yetkilendirin. İşlem tamamlandığında eklenti e-posta adresinizi ve kullanıcı kimliğinizi gösterir.

Eklenti 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 için ilk veri modeli oldukça basittir: Her biri görev açıklayıcı metnine ve bir zaman damgasına sahip Task varlıklarından oluşan bir liste.

Veri deposu dizinini oluşturma

Datastore, proje için codelab'in önceki bölümlerinde daha önce etkinleştirilmişti. Bu özellik, şema gerektirmez ancak bileşik sorgular için açıkça dizin oluşturmayı gerektirir. Dizinin oluşturulması birkaç dakika sürebilir; bu nedenle önce bu işlemi yapabilirsiniz.

Aşağıdakileri 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 klavyenizdeki ENTER tuşuna basın. Dizin oluşturma işlemi arka planda gerçekleşir. Bu aşamada, "yapılacaklar"ı 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'a okuma ve yazma

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

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

Datastore'dan görevleri okumak ve yazmak için yöntemler 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ü oluşturmayı uygulama

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

Codelab'in kullanıcı arayüzünde bir metin girişi ve görevlerin tamamlandı olarak işaretlendiği onay kutuları bulunan bir görev listesi yer alır. Bunların her biri, kullanıcı bir görev eklediğinde veya sildiğinde eklenti sunucusuna geri çağırmayla sonuçlanan bir onChangeAction özelliğine sahiptir. Bu durumların her birinde kullanıcı arayüzünün, güncellenen görev listesiyle yeniden oluşturulması gerekir. Şimdi, kart kullanıcı arayüzünü oluşturmak için yeni bir yöntem sunuyoruz.

index.js öğesini 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üncelle

Artık Datastore'da okuma, yazma ve kullanıcı arayüzünü derlemek için yardımcı yöntemler olduğuna göre bunları uygulama rotalarında birbirine bağlayabiliriz. Mevcut yolu değiştirip iki rota daha ekleyin: bunlardan biri görev eklemek, diğeri ise silmek içindir.

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

Tamamen işlevsel index.js dosyasının son hali aşağıda verilmiştir:

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ğıt ve test et

Eklentiyi yeniden derleyip dağıtmak için bir derleme başlatın. Cloud Shell'de şu komutu ç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 keşfedin. Girişe metin girip klavyenizdeki ENTER tuşuna basarak birkaç görev ekleyin. Ardından, görevleri silmek için onay kutusunu işaretleyin.

Görevler içeren eklenti

İsterseniz bu codelab'deki son adıma atlayıp projenizi temizleyebilirsiniz. Eklentiler hakkında daha fazla bilgi edinmeye devam etmek istiyorsanız uygulayabileceğiniz bir adım daha vardır.

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

Eklentilerin en güçlü özelliklerinden biri bağlam farkındalığıdır. Eklentiler, kullanıcı izniyle, kullanıcının baktığı e-posta, takvim etkinliği ve doküman gibi Google Workspace bağlamlarına erişebilir. Eklentiler, içerik ekleme gibi işlemler de yapabilir. Bu codelab'de, Workspace düzenleyicileri (Dokümanlar, E-Tablolar ve Slaytlar) için bağlam desteği ekleyerek mevcut dokümanı düzenleyicilerde oluşturulan görevlere ekleyeceksiniz. Görev görüntülendiğinde, tıklandığında doküman yeni bir sekmede açılır. Böylece kullanıcının görevini tamamlaması için doküman yeni bir sekmede açılır.

Eklenti arka ucunu güncelleme

newTask rotasını güncelle

İlk olarak, /newTask rotasını, varsa bir göreve doküman kimliğini dahil edecek şekilde güncelleyin:

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örevlere artık geçerli doküman kimliği eklendi. 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şmesine izin vermesi gerekir. Bilgilerin aşırı paylaşılmasını önlemek için tercih edilen yaklaşım, her dosya için ayrı izin talep etmek ve izin vermektir.

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

index.js uygulamasında, iki değişiklik yapmak için buildCard uygulamasını güncelleyin. Birincisi, görevlerin oluşturulmasını, varsa dokümanın bağlantısını içerecek şekilde güncellemek. İkincisi ise eklenti bir düzenleyicide oluşturulduysa ve dosya erişimi henüz verilmemişse isteğe bağlı bir yetkilendirme istemi görüntülemektir.

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 yolunu uygulama

Yetkilendirme düğmesi, uygulamaya yeni bir rota ekler. Bu nedenle, bunu uygulamaya geçirelim. Bu rotada yeni bir kavram olan ana makine uygulama işlemleri tanıtılmaktadır. Bunlar, eklentinin ana makine uygulamasıyla etkileşim kurmaya yönelik özel talimatlardır. Bu durumda, mevcut düzenleyici dosyasına erişim isteyebilirsiniz.

index.js içinde /authorizeFile rotasını ekleyin:

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

Tamamen işlevsel index.js dosyasının son hali aşağıda verilmiştir:

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 ekleyin

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

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

Şu 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ğıt ve test et

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

gcloud builds submit

İşlem tamamlandığında, Gmail'i açmak yerine mevcut bir Google dokümanını açın veya doc.new dosyasını açarak yeni bir doküman oluşturun. Yeni bir doküman oluşturuyorsanız metin girdiğinizden veya dosyaya bir ad verdiğinizden emin olun.

Eklentiyi açın. Eklentinin altında, Dosya Erişimini Yetkilendir düğmesi gösterilir. Düğmeyi tıklayın, ardından dosyaya erişim izni verin.

Yetki verildiğinde, düzenleyicideyken bir 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, açık olan dokümanı açmak biraz saçma gelebilir. Kullanıcı arayüzünü, geçerli dokümana ilişkin bağlantıları filtrelemek için optimize etmek istiyorsanız, bu ekstra krediyi kullanabilirsiniz.

8. Tebrikler

Tebrikler! Cloud Run'ı kullanarak başarıyla bir Google Workspace eklentisi derleyip dağıttınız. Codelab'de eklenti oluşturmayla ilgili temel kavramların çoğu ele alınmış olsa da keşfedilecek daha pek çok konu bulunuyor. Aşağıdaki kaynaklara bakı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 önlemek amacıyla:

  • 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çin ve Sil'i tıklayın.
  2. İletişim kutusuna proje kimliğini yazın ve projeyi silmek için Kapat'ı tıklayın.

Daha fazla bilgi