1. 소개
Google Workspace 부가기능은 Gmail, Docs, Sheets, Slides와 같은 Google Workspace 애플리케이션과 통합되는 맞춤 애플리케이션입니다. 이를 통해 개발자는 Google Workspace에 직접 통합된 맞춤설정된 사용자 인터페이스를 만들 수 있습니다. 부가기능을 사용하면 컨텍스트 전환을 줄이고 더 효율적으로 작업할 수 있습니다.
이 Codelab에서는 Node.js, Cloud Run, Datastore를 사용하여 간단한 작업 목록 부가기능을 빌드하고 배포하는 방법을 알아봅니다.
학습할 내용
- Cloud Shell 사용
- Cloud Run에 배포
- 부가기능 배포 설명자 생성 및 배포
- 카드 프레임워크로 부가기능 UI 만들기
- 사용자 상호작용에 응답
- 부가기능에서 사용자 컨텍스트 활용
2. 설정 및 요건
설정 안내에 따라 Google Cloud 프로젝트를 만들고 부가기능에서 사용할 API 및 서비스를 사용 설정합니다.
자습형 환경 설정
모든 Google Cloud 프로젝트에서 고유한 이름인 프로젝트 ID를 기억하세요(위의 이름은 이미 사용되었으므로 사용할 수 없습니다). 이 ID는 나중에 이 Codelab에서 PROJECT_ID
라고 부릅니다.
- 다음으로, Google Cloud 리소스를 사용하기 위해 Cloud 콘솔에서 결제를 사용 설정하세요.
이 Codelab 실행에는 많은 비용이 들지 않습니다. '삭제' 섹션을 참조하세요. 이 섹션에서는 이 튜토리얼을 마친 후에도 비용이 청구되지 않도록 리소스를 종료하는 방법을 확인할 수 있습니다. Google Cloud 새 사용자에게는 미화 $300 상당의 무료 체험판 프로그램에 참여할 수 있는 자격이 부여됩니다.
Google Cloud Shell
Google Cloud를 노트북에서 원격으로 실행할 수도 있지만 이 Codelab에서는 Cloud에서 실행되는 명령줄 환경인 Google Cloud Shell을 사용합니다.
Cloud Shell 활성화
- Cloud Console에서 Cloud Shell 활성화를 클릭합니다.
Cloud Shell을 처음 열면 구체적인 환영 메시지가 표시됩니다. 환영 메시지가 표시되면 계속을 클릭합니다. 환영 메시지는 다시 표시되지 않습니다. 다음은 환영 메시지입니다.
Cloud Shell을 프로비저닝하고 연결하는 데 몇 분 정도만 걸립니다. 연결되면 Cloud Shell 터미널이 표시됩니다.
가상 머신에는 필요한 개발 도구가 모두 들어 있습니다. 영구적인 5GB 홈 디렉터리를 제공하고 Google Cloud에서 실행되므로 네트워크 성능과 인증이 크게 개선됩니다. 이 Codelab의 모든 작업은 브라우저나 Chromebook을 사용하여 수행할 수 있습니다.
Cloud Shell에 연결되면 인증이 완료되었고 프로젝트가 해당 프로젝트 ID로 이미 설정된 것을 볼 수 있습니다.
- Cloud Shell에서 다음 명령어를 실행하여 인증되었는지 확인합니다.
gcloud auth list
Cloud Shell에서 GCP API를 호출할 수 있도록 승인하라는 메시지가 표시되면 승인을 클릭합니다.
명령어 결과
Credentialed Accounts ACTIVE ACCOUNT * <my_account>@<my_domain.com>
활성 계정을 설정하려면 다음을 실행합니다.
gcloud config set account <ACCOUNT>
올바른 프로젝트를 선택했는지 확인하려면 Cloud Shell에서 다음을 실행합니다.
gcloud config list project
명령어 결과
[core] project = <PROJECT_ID>
올바른 프로젝트가 반환되지 않으면 다음 명령어로 설정할 수 있습니다.
gcloud config set project <PROJECT_ID>
명령어 결과
Updated property [core/project].
이 Codelab에서는 파일 편집과 명령줄 작업을 함께 사용합니다. 파일을 편집하려면 Cloud Shell 툴바 오른쪽에 있는 편집기 열기 버튼을 클릭하여 Cloud Shell에 내장된 코드 편집기를 사용할 수 있습니다. 또한 Cloud Shell에서 vim 및 emacs와 같이 널리 사용되는 편집기도 찾아볼 수 있습니다.
3. Cloud Run, Datastore, Add-on API 사용 설정
Cloud API 사용 설정
Cloud Shell에서 사용할 구성요소에 Cloud API를 사용 설정합니다.
gcloud services enable \ run.googleapis.com \ cloudbuild.googleapis.com \ cloudresourcemanager.googleapis.com \ datastore.googleapis.com \ gsuiteaddons.googleapis.com
이 작업을 완료하는 데 몇 분 정도 걸릴 수 있습니다.
완료되면 다음과 유사한 성공 메시지가 표시됩니다.
Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.
Datastore 인스턴스 만들기
다음으로 App Engine을 사용 설정하고 Datastore 데이터베이스를 만듭니다. Datastore를 사용하기 위한 기본 요건은 App Engine이지만 다른 용도로는 App Engine을 사용하지 않습니다.
gcloud app create --region=us-central gcloud firestore databases create --type=datastore-mode --region=us-central
OAuth 동의 화면 만들기
부가기능을 실행하고 데이터를 대상으로 조치를 취하려면 사용자 권한이 필요합니다. 이 기능을 사용 설정하려면 프로젝트의 동의 화면을 구성하세요. 이 Codelab에서는 동의 화면을 내부 애플리케이션으로 구성해 시작합니다. 즉, 공개 배포용이 아니라는 의미입니다.
- 새 탭 또는 창에서 Google Cloud 콘솔을 엽니다.
- 'Google Cloud 콘솔' 옆에는 아래쪽 화살표 를 클릭하고 프로젝트를 선택합니다.
- 왼쪽 상단에서 메뉴 를 클릭합니다.
- API 및 서비스 > 사용자 인증 정보. 프로젝트의 사용자 인증 정보 페이지가 나타납니다.
- OAuth 동의 화면을 클릭합니다. 'OAuth 동의 화면' 화면이 나타납니다.
- '사용자 유형'에서 내부를 선택합니다. @gmail.com 계정을 사용하는 경우 외부를 선택합니다.
- 만들기를 클릭합니다. '앱 등록 수정' 페이지가 나타납니다.
- 양식을 작성합니다.
- App name에 'Todo Add-on'을 입력합니다.
- 사용자 지원 이메일에 개인 이메일 주소를 입력합니다.
- 개발자 연락처 정보에 개인 이메일 주소를 입력합니다.
- 저장하고 계속하기를 클릭합니다. 범위 양식이 나타납니다.
- 범위 양식에서 저장 후 계속을 클릭합니다. 요약이 표시됩니다.
- 대시보드로 돌아가기를 클릭합니다.
4. 초기 부가기능 만들기
프로젝트 초기화
먼저 간단한 'Hello world' 배포하는 방법을 살펴봤습니다 부가기능은 https 요청에 응답하고 UI 및 수행할 작업을 설명하는 JSON 페이로드로 응답하는 웹 서비스입니다. 이 부가기능에서는 Node.js 및 Express 프레임워크를 사용합니다.
이 템플릿 프로젝트를 만들려면 Cloud Shell을 사용하여 todo-add-on
이라는 새 디렉터리를 만들고 해당 디렉터리로 이동합니다.
mkdir ~/todo-add-on cd ~/todo-add-on
이 디렉터리에서 Codelab의 모든 작업을 실행합니다.
Node.js 프로젝트를 초기화합니다.
npm init
NPM에서 이름 및 버전과 같은 프로젝트 구성에 관한 몇 가지 질문을 합니다. 각 질문에서 ENTER
를 눌러 기본 값을 수락합니다. 기본 시작 지점은 index.js
라는 파일로, 이제 만들겠습니다.
다음으로 Express 웹 프레임워크를 설치합니다.
npm install --save express express-async-handler
부가기능 백엔드 만들기
앱을 만들 차례입니다.
index.js
파일을 만듭니다. Cloud Shell 창의 툴바에서 편집기 열기 버튼을 클릭하여 Cloud Shell 편집기를 사용해 파일을 만들 수 있습니다. 또는 vim 또는 emacs를 사용하여 Cloud Shell에서 파일을 편집하고 관리할 수 있습니다.
index.js
파일을 만든 후 다음 콘텐츠를 추가합니다.
const express = require('express');
const asyncHandler = require('express-async-handler');
// Create and configure the app
const app = express();
// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());
// Initial route for the add-on
app.post("/", asyncHandler(async (req, res) => {
const card = {
sections: [{
widgets: [
{
textParagraph: {
text: `Hello world!`
}
},
]
}]
};
const renderAction = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(renderAction);
}));
// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
});
서버는 'Hello world'를 표시하는 것 외에는 많은 작업을 하지 않습니다. 괜찮습니다. 나중에 더 많은 기능을 추가하겠습니다.
Cloud Run에 배포
Cloud Run에 배포하려면 앱을 컨테이너화해야 합니다.
컨테이너 만들기
다음을 포함하는 Dockerfile
라는 Dockerfile을 만듭니다.
FROM node:12-slim
# Create and change to the app directory.
WORKDIR /usr/src/app
# Copy application dependency manifests to the container image.
# A wildcard is used to ensure copying both package.json AND package-lock.json (when available).
# Copying this first prevents re-running npm install on every code change.
COPY package*.json ./
# Install production dependencies.
# If you add a package-lock.json, speed your build by switching to 'npm ci'.
# RUN npm ci --only=production
RUN npm install --only=production
# Copy local code to the container image.
COPY . ./
# Run the web service on container startup.
CMD [ "node", "index.js" ]
컨테이너에 원치 않는 파일을 보관하지 마세요.
컨테이너를 가볍게 유지하려면 다음을 포함하는 .dockerignore
파일을 만듭니다.
Dockerfile
.dockerignore
node_modules
npm-debug.log
Cloud Build 사용 설정
이 Codelab에서는 새로운 기능이 추가됨에 따라 부가기능을 여러 번 빌드하고 배포합니다. 별도의 명령어를 실행하여 컨테이너를 빌드하고 컨테이너 등록기에 푸시한 다음 Cloud Build에 배포하는 대신 Cloud Build를 사용하여 절차를 조정하세요. 애플리케이션을 빌드하고 배포하는 방법에 대한 안내가 포함된 cloudbuild.yaml
파일을 만듭니다.
steps:
# Build the container image
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME', '.']
# Push the container image to Container Registry
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME']
# Deploy container image to Cloud Run
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args:
- 'run'
- 'deploy'
- '$_SERVICE_NAME'
- '--image'
- 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
- '--region'
- '$_REGION'
- '--platform'
- 'managed'
images:
- 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
substitutions:
_SERVICE_NAME: todo-add-on
_REGION: us-central1
다음 명령어를 실행하여 앱을 배포할 수 있는 권한을 Cloud Build에 부여합니다.
PROJECT_ID=$(gcloud config list --format='value(core.project)') PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)') gcloud projects add-iam-policy-binding $PROJECT_ID \ --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \ --role=roles/run.admin gcloud iam service-accounts add-iam-policy-binding \ $PROJECT_NUMBER-compute@developer.gserviceaccount.com \ --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \ --role=roles/iam.serviceAccountUser
부가기능 백엔드 빌드 및 배포
빌드를 시작하려면 Cloud Shell에서 다음을 실행합니다.
gcloud builds submit
전체 빌드 및 배포를 완료하는 데 몇 분 정도 걸릴 수 있으며 특히 처음 하는 경우에는 더욱 그렇습니다.
빌드가 완료되면 서비스가 배포되었는지 확인하고 URL을 찾습니다. 다음 명령어를 실행합니다.
gcloud run services list --platform managed
이 URL을 복사하세요. 다음 단계에서 부가기능을 호출하는 방법을 Google Workspace에 알리는 데 필요합니다.
부가기능 등록
이제 서버가 실행 중이므로 Google Workspace에서 부가기능을 표시하고 호출하는 방법을 알 수 있도록 부가기능을 설명합니다.
배포 설명자 만들기
다음 콘텐츠로 deployment.json
파일을 만듭니다. URL
자리표시자 대신 배포된 앱의 URL을 사용해야 합니다.
{
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute"
],
"addOns": {
"common": {
"name": "Todo Codelab",
"logoUrl": "https://raw.githubusercontent.com/webdog/octicons-png/main/black/check.png",
"homepageTrigger": {
"runFunction": "URL"
}
},
"gmail": {},
"drive": {},
"calendar": {},
"docs": {},
"sheets": {},
"slides": {}
}
}
다음 명령어를 실행하여 배포 설명자를 업로드합니다.
gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json
부가기능 백엔드에 대한 액세스 승인
부가기능 프레임워크에는 서비스를 호출할 수 있는 권한도 필요합니다. 다음 명령어를 실행하여 Google Workspace가 부가기능을 호출할 수 있도록 Cloud Run의 IAM 정책을 업데이트합니다.
SERVICE_ACCOUNT_EMAIL=$(gcloud workspace-add-ons get-authorization --format="value(serviceAccountEmail)")
gcloud run services add-iam-policy-binding todo-add-on --platform managed --region us-central1 --role roles/run.invoker --member "serviceAccount:$SERVICE_ACCOUNT_EMAIL"
테스트를 위해 부가기능 설치하기
계정의 개발 모드로 부가기능을 설치하려면 Cloud Shell에서 다음을 실행합니다.
gcloud workspace-add-ons deployments install todo-add-on
새 탭 또는 창에서 (Gmail)[https://mail.google.com/] 을 엽니다. 오른쪽에서 체크표시 아이콘이 있는 부가기능을 찾습니다.
체크박스를 클릭하여 부가기능을 엽니다. 부가기능을 승인하라는 메시지가 표시됩니다.
액세스 승인을 클릭하고 팝업에 표시되는 승인 흐름 안내를 따릅니다. 완료되면 부가기능이 자동으로 새로고침되어 ‘Hello world!’ 메시지가 표시됩니다.
축하합니다. 이제 간단한 부가기능이 배포 및 설치되었습니다. 이제 작업 목록 애플리케이션으로 변환해 보겠습니다.
5. 사용자 ID 액세스
부가기능은 일반적으로 많은 사용자가 자신 또는 조직의 비공개 정보를 사용하기 위해 사용합니다. 이 Codelab의 부가기능은 현재 사용자의 작업만 표시합니다. 사용자 ID는 디코딩되어야 하는 ID 토큰을 통해 부가기능으로 전송됩니다.
배포 설명자에 범위 추가
사용자 ID는 기본적으로 전송되지 않습니다. 사용자 데이터이며 부가기능에서 해당 데이터에 액세스하려면 권한이 필요합니다. 권한을 얻으려면 deployment.json
를 업데이트하고 부가기능에 필요한 범위 목록에 openid
및 email
OAuth 범위를 추가하세요. OAuth 범위를 추가하면 사용자가 다음에 부가기능을 사용할 때 액세스 권한을 부여하라는 메시지가 표시됩니다.
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute",
"openid",
"email"
],
그런 다음 Cloud Shell에서 다음 명령어를 실행하여 배포 설명자를 업데이트합니다.
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
부가기능 서버 업데이트
부가기능이 사용자 ID를 요청하도록 구성되어 있지만 구현을 업데이트해야 합니다.
ID 토큰 파싱
먼저 프로젝트에 Google 인증 라이브러리를 추가합니다.
npm install --save google-auth-library
그런 다음 OAuth2Client
를 요구하도록 index.js
를 수정합니다.
const { OAuth2Client } = require('google-auth-library');
그런 다음 ID 토큰을 파싱하는 도우미 메서드를 추가합니다.
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
사용자 ID 표시
이제 모든 작업 목록 기능을 추가하기 전에 체크포인트를 살펴 보는 것이 좋습니다. 'Hello world' 대신 사용자의 이메일 주소와 고유 ID를 출력하도록 앱의 경로를 업데이트합니다.
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const card = {
sections: [{
widgets: [
{
textParagraph: {
text: `Hello ${user.email} ${user.sub}`
}
},
]
}]
};
const renderAction = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(renderAction);
}));
위와 같이 변경한 후 결과 index.js
파일은 다음과 같습니다.
const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
// Create and configure the app
const app = express();
// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());
// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const card = {
sections: [{
widgets: [
{
textParagraph: {
text: `Hello ${user.email} ${user.sub}`
}
},
]
}]
};
const renderAction = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(renderAction);
}));
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
});
재배포 및 테스트
부가기능을 다시 빌드하고 재배포합니다. Cloud Shell에서 다음을 실행합니다.
gcloud builds submit
서버가 재배포되면 Gmail을 열거나 새로고침하고 부가기능을 다시 엽니다. 범위가 변경되었으므로 부가기능에서 재승인을 요청합니다. 부가기능을 다시 승인하고 완료되면 부가기능에 이메일 주소와 사용자 ID가 표시됩니다.
이제 부가기능에서 사용자가 누구인지 알았으므로 할 일 목록 기능을 추가할 수 있습니다.
6. 할 일 목록 구현
Codelab의 초기 데이터 모델은 간단합니다. Task
항목의 목록으로, 각 항목에는 작업 설명 텍스트와 타임스탬프의 속성이 있습니다.
데이터 저장소 색인 만들기
Datastore는 Codelab 앞부분에서 이미 프로젝트에 사용 설정되어 있습니다. 복합 쿼리의 색인을 명시적으로 만들어야 하지만 스키마는 필요하지 않습니다. 색인을 만드는 데 몇 분 정도 걸릴 수 있으므로 먼저 만듭니다.
다음을 사용하여 index.yaml
이라는 파일을 만듭니다.
indexes:
- kind: Task
ancestor: yes
properties:
- name: created
그런 다음 Datastore 색인을 업데이트합니다.
gcloud datastore indexes create index.yaml
계속할지 묻는 메시지가 나타나면 키보드에서 Enter 키를 누릅니다. 색인 생성은 백그라운드에서 이루어집니다. 이 작업이 진행되는 동안 부가기능 코드를 업데이트하여 '할 일'을 구현하세요.
부가기능 백엔드 업데이트
프로젝트에 Datastore 라이브러리를 설치합니다.
npm install --save @google-cloud/datastore
Datastore 읽기 및 쓰기
index.js
를 업데이트하여 '할 일' 구현 다음과 같이 Datastore 라이브러리를 가져오고 클라이언트를 만듭니다.
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
Datastore에서 작업을 읽고 쓰는 메서드를 추가합니다.
async function listTasks(userId) {
const parentKey = datastore.key(['User', userId]);
const query = datastore.createQuery('Task')
.hasAncestor(parentKey)
.order('created')
.limit(20);
const [tasks] = await datastore.runQuery(query);
return tasks;;
}
async function addTask(userId, task) {
const key = datastore.key(['User', userId, 'Task']);
const entity = {
key,
data: task,
};
await datastore.save(entity);
return entity;
}
async function deleteTasks(userId, taskIds) {
const keys = taskIds.map(id => datastore.key(['User', userId,
'Task', datastore.int(id)]));
await datastore.delete(keys);
}
UI 렌더링 구현
대부분의 변경사항은 부가기능 UI입니다. 이전에는 UI에서 반환된 모든 카드가 정적이었습니다. 즉, 사용 가능한 데이터에 따라 변경되지 않았습니다. 여기서 카드는 사용자의 현재 작업 목록에 기반하여 동적으로 구성되어야 합니다.
Codelab의 UI는 텍스트 입력과 완료로 표시하는 체크박스가 있는 작업 목록으로 구성됩니다. 또한 각 클래스에는 사용자가 작업을 추가하거나 삭제할 때 부가기능 서버로 콜백이 발생하는 onChangeAction
속성이 있습니다. 각각의 경우 업데이트된 작업 목록으로 UI를 다시 렌더링해야 합니다. 이를 처리하기 위해 카드 UI를 빌드하는 새 메서드를 도입해 보겠습니다.
계속해서 index.js
를 수정하고 다음 메서드를 추가합니다.
function buildCard(req, tasks) {
const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
// Input for adding a new task
const inputSection = {
widgets: [
{
textInput: {
label: 'Task to add',
name: 'newTask',
value: '',
onChangeAction: {
function: `${baseUrl}/newTask`,
},
}
}
]
};
const taskListSection = {
header: 'Your tasks',
widgets: []
};
if (tasks && tasks.length) {
// Create text & checkbox for each task
tasks.forEach(task => taskListSection.widgets.push({
decoratedText: {
text: task.text,
wrapText: true,
switchControl: {
controlType: 'CHECKBOX',
name: 'completedTasks',
value: task[datastore.KEY].id,
selected: false,
onChangeAction: {
function: `${baseUrl}/complete`,
}
}
}
}));
} else {
// Placeholder for empty task list
taskListSection.widgets.push({
textParagraph: {
text: 'Your task list is empty.'
}
});
}
const card = {
sections: [
inputSection,
taskListSection,
]
}
return card;
}
경로 업데이트
이제 Datastore를 읽고 쓰고 UI를 빌드하는 도우미 메서드가 있으므로 이러한 메서드를 앱 경로에 함께 연결해 보겠습니다. 기존 경로를 바꾸고 작업 추가용 1개와 삭제용 경로 1개를 더 추가합니다.
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(responsePayload);
}));
app.post('/newTask', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const newTask = formInputs.newTask;
if (!newTask || !newTask.stringInputs) {
return {};
}
const task = {
text: newTask.stringInputs.value[0],
created: new Date()
};
await addTask(user.sub, task);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task added.'
},
}
}
};
res.json(responsePayload);
}));
app.post('/complete', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const completedTasks = formInputs.completedTasks;
if (!completedTasks || !completedTasks.stringInputs) {
return {};
}
await deleteTasks(user.sub, completedTasks.stringInputs.value);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task completed.'
},
}
}
};
res.json(responsePayload);
}));
다음은 완전히 작동하는 최종 index.js
파일입니다.
const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
// Create and configure the app
const app = express();
// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());
// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(responsePayload);
}));
app.post('/newTask', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const newTask = formInputs.newTask;
if (!newTask || !newTask.stringInputs) {
return {};
}
const task = {
text: newTask.stringInputs.value[0],
created: new Date()
};
await addTask(user.sub, task);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task added.'
},
}
}
};
res.json(responsePayload);
}));
app.post('/complete', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const completedTasks = formInputs.completedTasks;
if (!completedTasks || !completedTasks.stringInputs) {
return {};
}
await deleteTasks(user.sub, completedTasks.stringInputs.value);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task completed.'
},
}
}
};
res.json(responsePayload);
}));
function buildCard(req, tasks) {
const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
// Input for adding a new task
const inputSection = {
widgets: [
{
textInput: {
label: 'Task to add',
name: 'newTask',
value: '',
onChangeAction: {
function: `${baseUrl}/newTask`,
},
}
}
]
};
const taskListSection = {
header: 'Your tasks',
widgets: []
};
if (tasks && tasks.length) {
// Create text & checkbox for each task
tasks.forEach(task => taskListSection.widgets.push({
decoratedText: {
text: task.text,
wrapText: true,
switchControl: {
controlType: 'CHECKBOX',
name: 'completedTasks',
value: task[datastore.KEY].id,
selected: false,
onChangeAction: {
function: `${baseUrl}/complete`,
}
}
}
}));
} else {
// Placeholder for empty task list
taskListSection.widgets.push({
textParagraph: {
text: 'Your task list is empty.'
}
});
}
const card = {
sections: [
inputSection,
taskListSection,
]
}
return card;
}
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
async function listTasks(userId) {
const parentKey = datastore.key(['User', userId]);
const query = datastore.createQuery('Task')
.hasAncestor(parentKey)
.order('created')
.limit(20);
const [tasks] = await datastore.runQuery(query);
return tasks;;
}
async function addTask(userId, task) {
const key = datastore.key(['User', userId, 'Task']);
const entity = {
key,
data: task,
};
await datastore.save(entity);
return entity;
}
async function deleteTasks(userId, taskIds) {
const keys = taskIds.map(id => datastore.key(['User', userId,
'Task', datastore.int(id)]));
await datastore.delete(keys);
}
// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
});
재배포 및 테스트
부가기능을 다시 빌드하고 재배포하려면 빌드를 시작하세요. Cloud Shell에서 다음을 실행합니다.
gcloud builds submit
Gmail에서 부가기능을 새로고침하면 새 UI가 표시됩니다. 잠시 시간을 내어 부가기능을 살펴보세요. 입력에 텍스트를 입력하고 키보드에서 Enter 키를 눌러 몇 가지 작업을 추가한 후 체크박스를 클릭하여 삭제합니다.
원하는 경우 이 Codelab의 마지막 단계로 건너뛰고 프로젝트를 삭제할 수 있습니다. 또는 부가 기능에 대해 자세히 알고 싶다면 다음 단계를 하나 더 완료하시면 됩니다.
7. (선택사항) 컨텍스트 추가
부가기능의 가장 강력한 기능 중 하나는 컨텍스트 인식입니다. 부가기능은 사용자 권한이 있는 경우 사용자가 보고 있는 이메일, 캘린더 일정, 문서와 같은 Google Workspace 컨텍스트에 액세스할 수 있습니다. 부가기능은 콘텐츠 삽입과 같은 작업도 할 수 있습니다. 이 Codelab에서는 Workspace 편집기 (Docs, Sheets, Slides)를 위한 컨텍스트 지원을 추가하여 편집기 내에서 생성된 모든 작업에 현재 문서를 첨부합니다. 할 일이 표시될 때 클릭하면 새 탭에서 문서가 열리고 사용자가 문서로 돌아가 작업을 완료할 수 있습니다.
부가기능 백엔드 업데이트
newTask
경로 업데이트
먼저 가능한 경우 작업에 문서 ID를 포함하도록 /newTask
경로를 업데이트합니다.
app.post('/newTask', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const newTask = formInputs.newTask;
if (!newTask || !newTask.stringInputs) {
return {};
}
// Get the current document if it is present
const editorInfo = event.docs || event.sheets || event.slides;
let document = null;
if (editorInfo && editorInfo.id) {
document = {
id: editorInfo.id,
}
}
const task = {
text: newTask.stringInputs.value[0],
created: new Date(),
document,
};
await addTask(user.sub, task);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task added.'
},
}
}
};
res.json(responsePayload);
}));
이제 새로 만든 작업에 현재 문서 ID가 포함됩니다. 그러나 편집기의 컨텍스트는 기본적으로 공유되지 않습니다. 다른 사용자 데이터와 마찬가지로 사용자는 부가기능이 데이터에 액세스할 수 있도록 권한을 부여해야 합니다. 과도한 정보 공유를 방지하려면 파일별로 권한을 요청하고 부여하는 것이 좋습니다.
UI 업데이트
index.js
에서 buildCard
를 업데이트하여 두 가지 변경사항을 적용합니다. 첫 번째는 문서 링크가 있는 경우 이를 포함하도록 작업의 렌더링을 업데이트하는 것입니다. 두 번째는 부가기능이 편집기에서 렌더링되고 파일 액세스 권한이 아직 부여되지 않은 경우 승인 메시지(선택사항)를 표시하는 것입니다.
function buildCard(req, tasks) {
const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
const inputSection = {
widgets: [
{
textInput: {
label: 'Task to add',
name: 'newTask',
value: '',
onChangeAction: {
function: `${baseUrl}/newTask`,
},
}
}
]
};
const taskListSection = {
header: 'Your tasks',
widgets: []
};
if (tasks && tasks.length) {
tasks.forEach(task => {
const widget = {
decoratedText: {
text: task.text,
wrapText: true,
switchControl: {
controlType: 'CHECKBOX',
name: 'completedTasks',
value: task[datastore.KEY].id,
selected: false,
onChangeAction: {
function: `${baseUrl}/complete`,
}
}
}
};
// Make item clickable and open attached doc if present
if (task.document) {
widget.decoratedText.bottomLabel = 'Click to open document.';
const id = task.document.id;
const url = `https://drive.google.com/open?id=${id}`
widget.decoratedText.onClick = {
openLink: {
openAs: 'FULL_SIZE',
onClose: 'NOTHING',
url: url,
}
}
}
taskListSection.widgets.push(widget)
});
} else {
taskListSection.widgets.push({
textParagraph: {
text: 'Your task list is empty.'
}
});
}
const card = {
sections: [
inputSection,
taskListSection,
]
};
// Display file authorization prompt if the host is an editor
// and no doc ID present
const event = req.body;
const editorInfo = event.docs || event.sheets || event.slides;
const showFileAuth = editorInfo && editorInfo.id === undefined;
if (showFileAuth) {
card.fixedFooter = {
primaryButton: {
text: 'Authorize file access',
onClick: {
action: {
function: `${baseUrl}/authorizeFile`,
}
}
}
}
}
return card;
}
파일 승인 경로 구현
승인 버튼은 앱에 새 경로를 추가하므로 구현해 보겠습니다. 이 경로는 호스트 앱 작업이라는 새로운 개념을 도입합니다. 다음은 부가기능의 호스트 애플리케이션과 상호작용하기 위한 특별 안내입니다. 이 경우 현재 편집기 파일에 대한 액세스 권한을 요청합니다.
index.js
에서 /authorizeFile
경로를 추가합니다.
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
다음은 완전히 작동하는 최종 index.js
파일입니다.
const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
// Create and configure the app
const app = express();
// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());
// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(responsePayload);
}));
app.post('/newTask', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const newTask = formInputs.newTask;
if (!newTask || !newTask.stringInputs) {
return {};
}
// Get the current document if it is present
const editorInfo = event.docs || event.sheets || event.slides;
let document = null;
if (editorInfo && editorInfo.id) {
document = {
id: editorInfo.id,
}
}
const task = {
text: newTask.stringInputs.value[0],
created: new Date(),
document,
};
await addTask(user.sub, task);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task added.'
},
}
}
};
res.json(responsePayload);
}));
app.post('/complete', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const completedTasks = formInputs.completedTasks;
if (!completedTasks || !completedTasks.stringInputs) {
return {};
}
await deleteTasks(user.sub, completedTasks.stringInputs.value);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task completed.'
},
}
}
};
res.json(responsePayload);
}));
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
function buildCard(req, tasks) {
const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
const inputSection = {
widgets: [
{
textInput: {
label: 'Task to add',
name: 'newTask',
value: '',
onChangeAction: {
function: `${baseUrl}/newTask`,
},
}
}
]
};
const taskListSection = {
header: 'Your tasks',
widgets: []
};
if (tasks && tasks.length) {
tasks.forEach(task => {
const widget = {
decoratedText: {
text: task.text,
wrapText: true,
switchControl: {
controlType: 'CHECKBOX',
name: 'completedTasks',
value: task[datastore.KEY].id,
selected: false,
onChangeAction: {
function: `${baseUrl}/complete`,
}
}
}
};
// Make item clickable and open attached doc if present
if (task.document) {
widget.decoratedText.bottomLabel = 'Click to open document.';
const id = task.document.id;
const url = `https://drive.google.com/open?id=${id}`
widget.decoratedText.onClick = {
openLink: {
openAs: 'FULL_SIZE',
onClose: 'NOTHING',
url: url,
}
}
}
taskListSection.widgets.push(widget)
});
} else {
taskListSection.widgets.push({
textParagraph: {
text: 'Your task list is empty.'
}
});
}
const card = {
sections: [
inputSection,
taskListSection,
]
};
// Display file authorization prompt if the host is an editor
// and no doc ID present
const event = req.body;
const editorInfo = event.docs || event.sheets || event.slides;
const showFileAuth = editorInfo && editorInfo.id === undefined;
if (showFileAuth) {
card.fixedFooter = {
primaryButton: {
text: 'Authorize file access',
onClick: {
action: {
function: `${baseUrl}/authorizeFile`,
}
}
}
}
}
return card;
}
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
async function listTasks(userId) {
const parentKey = datastore.key(['User', userId]);
const query = datastore.createQuery('Task')
.hasAncestor(parentKey)
.order('created')
.limit(20);
const [tasks] = await datastore.runQuery(query);
return tasks;;
}
async function addTask(userId, task) {
const key = datastore.key(['User', userId, 'Task']);
const entity = {
key,
data: task,
};
await datastore.save(entity);
return entity;
}
async function deleteTasks(userId, taskIds) {
const keys = taskIds.map(id => datastore.key(['User', userId,
'Task', datastore.int(id)]));
await datastore.delete(keys);
}
// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
});
배포 설명자에 범위 추가
서버를 다시 빌드하기 전에 https://www.googleapis.com/auth/drive.file
OAuth 범위를 포함하도록 부가기능 배포 설명자를 업데이트합니다. deployment.json
를 업데이트하여 OAuth 범위 목록에 https://www.googleapis.com/auth/drive.file
를 추가합니다.
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute",
"https://www.googleapis.com/auth/drive.file",
"openid",
"email"
]
다음 Cloud Shell 명령어를 실행하여 새 버전을 업로드합니다.
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
재배포 및 테스트
마지막으로 서버를 다시 빌드합니다. Cloud Shell에서 다음을 실행합니다.
gcloud builds submit
완료되면 Gmail을 여는 대신 기존 Google 문서를 열거나 doc.new를 열어 새 문서를 만드세요. 새 문서를 만드는 경우 텍스트를 입력하거나 파일 이름을 지정해야 합니다.
부가기능을 엽니다. 부가기능 하단에 파일 액세스 승인 버튼이 표시됩니다. 버튼을 클릭한 다음 파일에 대한 액세스를 승인합니다.
승인되면 편집기에서 작업을 추가합니다. 작업에는 문서가 첨부되었음을 나타내는 라벨이 있습니다. 링크를 클릭하면 문서가 새 탭에서 열립니다. 물론 이미 열려 있는 문서를 여는 것은 약간 어리석은 일입니다. 현재 문서의 링크를 필터링하도록 UI를 최적화하려면 추가 크레딧을 고려해 보세요.
8. 축하합니다
축하합니다. Cloud Run을 사용하여 Google Workspace 부가기능을 성공적으로 빌드하고 배포했습니다. 이 Codelab에서 부가기능 빌드에 관한 여러 핵심 개념을 살펴보았지만 살펴볼 내용이 더 많이 있습니다. 아래 리소스를 확인해보고 추가 비용이 청구되지 않도록 프로젝트를 삭제하시기 바랍니다.
삭제
계정에서 부가기능을 제거하려면 Cloud Shell에서 다음 명령어를 실행합니다.
gcloud workspace-add-ons deployments uninstall todo-add-on
이 튜토리얼에서 사용한 리소스 비용이 Google Cloud Platform 계정에 청구되지 않도록 하는 방법은 다음과 같습니다.
- Cloud 콘솔에서 리소스 관리 페이지로 이동합니다. 왼쪽 상단에서 메뉴 > IAM 및 관리자 > 리소스 관리를 클릭합니다.
- 프로젝트 목록에서 해당 프로젝트를 선택한 후 삭제를 클릭합니다.
- 대화상자에서 프로젝트 ID를 입력한 후 종료를 클릭하여 프로젝트를 삭제합니다.
자세히 알아보기
- Google Workspace 부가기능 개요
- Marketplace에서 기존 앱 및 부가기능 찾기