Node.js 및 Cloud Run을 사용하여 Google Workspace 부가기능 빌드

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 및 서비스를 사용 설정합니다.

자습형 환경 설정

  1. Cloud 콘솔을 열고 새 프로젝트를 만듭니다. (Gmail 또는 Google Workspace 계정이 없으면 계정을 만드세요.)

프로젝트 선택 메뉴

새 프로젝트 버튼

프로젝트 ID

모든 Google Cloud 프로젝트에서 고유한 이름인 프로젝트 ID를 기억하세요(위의 이름은 이미 사용되었으므로 사용할 수 없습니다). 이 ID는 나중에 이 Codelab에서 PROJECT_ID라고 부릅니다.

  1. 다음으로, Google Cloud 리소스를 사용하기 위해 Cloud 콘솔에서 결제를 사용 설정하세요.

이 Codelab 실행에는 많은 비용이 들지 않습니다. 이 튜토리얼을 완료한 이후에 비용이 청구되지 않도록 이 Codelab 끝부분에서 리소스 삭제 방법을 알려주는 '삭제' 섹션에 있는 안내를 따르세요. Google Cloud 새 사용자에게는 미화 $300 상당의 무료 체험판 프로그램에 참여할 수 있는 자격이 부여됩니다.

Google Cloud Shell

Google Cloud를 노트북에서 원격으로 실행할 수 있지만, 이 Codelab에서는 Cloud에서 실행되는 명령줄 환경인 Google Cloud Shell을 사용합니다.

Cloud Shell 활성화

  1. Cloud Console에서 Cloud Shell 활성화Cloud Shell 아이콘를 클릭합니다.

메뉴 바의 Cloud Shell 아이콘

Cloud Shell을 처음 열면 설명이 포함된 환영 메시지가 표시됩니다. 환영 메시지가 표시되면 계속을 클릭합니다. 환영 메시지는 다시 표시되지 않습니다. 환영 메시지는 다음과 같습니다.

Cloud Shell 환영 메시지

Cloud Shell을 프로비저닝하고 연결하는 작업은 몇 분이면 끝납니다. 연결되면 Cloud Shell 터미널이 표시됩니다.

Cloud Shell 터미널

이 가상 머신에는 필요한 개발 도구가 모두 포함되어 로드됩니다. 영구적인 5GB 홈 디렉터리를 제공하고 Google Cloud에서 실행되므로 네트워크 성능과 인증이 크게 개선됩니다. 이 Codelab의 모든 작업은 브라우저나 Chromebook에서 수행할 수 있습니다.

Cloud Shell에 연결되면 인증이 완료되었고 프로젝트가 해당 프로젝트 ID로 이미 설정된 것을 볼 수 있습니다.

  1. 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, 부가기능 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.

데이터 스토어 인스턴스 만들기

다음으로 App Engine을 사용 설정하고 Datastore 데이터베이스를 만듭니다. Datastore를 사용하려면 App Engine을 사용 설정해야 하지만 다른 용도로는 App Engine을 사용하지 않습니다.

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

부가기능이 실행되고 데이터에 대해 조치를 취하려면 사용자 권한이 필요합니다. 이를 사용 설정하려면 프로젝트의 동의 화면을 구성하세요. 이 Codelab에서는 시작하기 위해 동의 화면을 내부 애플리케이션으로 구성합니다. 즉, 공개 배포용이 아닙니다.

  1. 새 탭이나 창에서 Google Cloud 콘솔을 엽니다.
  2. 'Google Cloud 콘솔' 옆에 있는 아래쪽 화살표 드롭다운 화살표를 클릭하고 프로젝트를 선택합니다.
  3. 왼쪽 상단에서 메뉴 메뉴 아이콘를 클릭합니다.
  4. API 및 서비스 > 사용자 인증 정보를 클릭합니다. 프로젝트의 사용자 인증 정보 페이지가 표시됩니다.
  5. OAuth 동의 화면을 클릭합니다. 'OAuth 동의 화면' 화면이 표시됩니다.
  6. '사용자 유형'에서 내부를 선택합니다. @gmail.com 계정을 사용하는 경우 외부를 선택합니다.
  7. 만들기를 클릭합니다. '앱 등록 수정' 페이지가 표시됩니다.
  8. 양식을 작성합니다.
    • 앱 이름에 '할 일 애드온'을 입력합니다.
    • 사용자 지원 이메일에 개인 이메일 주소를 입력합니다.
    • 개발자 연락처 정보에 개인 이메일 주소를 입력합니다.
  9. 저장하고 계속하기를 클릭합니다. 범위 양식이 표시됩니다.
  10. 범위 양식에서 저장 후 계속을 클릭합니다. 요약이 표시됩니다.
  11. 대시보드로 돌아가기를 클릭합니다.

4. 초기 부가기능 만들기

프로젝트 초기화

먼저 간단한 'Hello world' 부가기능을 만들어 배포합니다. 부가기능은 HTTP 요청에 응답하고 취해야 할 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를 업데이트하고 openidemail 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 항목 목록으로, 각 항목에는 작업 설명 텍스트와 타임스탬프의 속성이 있습니다.

데이터 스토어 색인 만들기

Codelab의 앞부분에서 프로젝트에 대해 데이터 저장소가 이미 사용 설정되었습니다. 스키마가 필요하지 않지만 복합 쿼리의 경우 색인을 명시적으로 만들어야 합니다. 색인을 만드는 데 몇 분 정도 걸릴 수 있으므로 먼저 색인을 만듭니다.

다음 콘텐츠로 index.yaml라는 파일을 만듭니다.

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

그런 다음 Datastore 색인을 업데이트합니다.

gcloud datastore indexes create index.yaml

계속할지 묻는 메시지가 표시되면 키보드에서 ENTER 키를 누릅니다. 색인 생성은 백그라운드에서 이루어집니다. 이 과정이 진행되는 동안 부가기능 코드를 업데이트하여 '할 일'을 구현합니다.

부가기능 백엔드 업데이트

Datastore 라이브러리를 프로젝트에 설치합니다.

npm install --save @google-cloud/datastore

Datastore 읽기 및 쓰기

데이터 스토어 라이브러리를 가져오고 클라이언트를 만드는 것으로 시작하여 index.js를 업데이트하여 '할 일'을 구현합니다.

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

Datastore에서 작업을 읽고 쓰는 메서드를 추가합니다.

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

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

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

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를 빌드하는 도우미 메서드가 있으므로 앱 경로에서 이를 연결해 보겠습니다. 기존 경로를 대체하고 태스크 추가용 경로와 태스크 삭제용 경로를 각각 하나씩 추가합니다.

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 및 관리자 > 리소스 관리를 클릭합니다.
  1. 프로젝트 목록에서 해당 프로젝트를 선택한 후 삭제를 클릭합니다.
  2. 대화상자에서 프로젝트 ID를 입력한 후 종료를 클릭하여 프로젝트를 삭제합니다.

자세히 알아보기

  • Google Workspace 부가기능 개요
  • Marketplace에서 기존 앱과 부가기능 찾기