diff --git a/.kokoro/cloud-tasks/cloud-tasks-app.cfg b/.kokoro/cloud-tasks/cloud-tasks-app.cfg new file mode 100644 index 0000000000..0fa7b095ee --- /dev/null +++ b/.kokoro/cloud-tasks/cloud-tasks-app.cfg @@ -0,0 +1,7 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Set the folder in which the tests are run +env_vars: { + key: "PROJECT" + value: "cloud-tasks/app" +} diff --git a/.kokoro/cloud-tasks/cloud-tasks-func.cfg b/.kokoro/cloud-tasks/cloud-tasks-func.cfg new file mode 100644 index 0000000000..399ba5c88d --- /dev/null +++ b/.kokoro/cloud-tasks/cloud-tasks-func.cfg @@ -0,0 +1,7 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Set the folder in which the tests are run +env_vars: { + key: "PROJECT" + value: "cloud-tasks/function" +} diff --git a/.kokoro/cloud-tasks/common.cfg b/.kokoro/cloud-tasks/common.cfg new file mode 100644 index 0000000000..bdfe169225 --- /dev/null +++ b/.kokoro/cloud-tasks/common.cfg @@ -0,0 +1,22 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Download trampoline resources. These will be in ${KOKORO_GFILE_DIR} +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" + +# Download secrets from Cloud Storage. +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/nodejs-docs-samples" + +# All builds use the trampoline script to run in docker. +build_file: "nodejs-docs-samples/.kokoro/trampoline.sh" + +# Tell the trampoline which build file to use. +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/nodejs-docs-samples/.kokoro/build.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/node:10-user" +} diff --git a/cloud-tasks/README.md b/cloud-tasks/README.md new file mode 100644 index 0000000000..9d6f5b7b06 --- /dev/null +++ b/cloud-tasks/README.md @@ -0,0 +1,22 @@ +# [Node.js Cloud Tasks sample for Google App Engine and Cloud Functions][tutorial-link] + +This is the sample application for the +[Using Cloud Tasks to trigger Cloud Functions][tutorial-link] tutorial. + +This tutorial shows how to create [Cloud Tasks][cloud-tasks] on +[Google App Engine Standard][gae-std] to trigger a [Cloud Function][cloud-func] +in order to send a postcard email. + +## Application Architecture + +* The App Engine application calls the Cloud Tasks API to add a scheduled task +to the queue. + +* The queue processes tasks and sends requests to a Cloud Function. + +* The Cloud Function calls the SendGrid API to send a postcard email. + +[tutorial-link]: https://cloud.google.com/tasks/docs/tutorial-gcf +[cloud-tasks]: https://cloud.google.com/tasks/docs/ +[gae-std]: https://cloud.google.com/appengine/docs/standard/nodejs/ +[cloud-func]: https://cloud.google.com/functions/ diff --git a/cloud-tasks/app/app.yaml b/cloud-tasks/app/app.yaml new file mode 100644 index 0000000000..35dce0f12a --- /dev/null +++ b/cloud-tasks/app/app.yaml @@ -0,0 +1,33 @@ +# Copyright 2019 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# [START cloud_tasks_app_yaml] +runtime: nodejs10 + +# [START cloud_tasks_app_env_vars] +env_variables: + QUEUE_NAME: "my-queue" + QUEUE_LOCATION: "us-central1" + FUNCTION_URL: "https://-.cloudfunctions.net/sendEmail" + SERVICE_ACCOUNT_EMAIL: "@.iam.gserviceaccount.com" +# [END cloud_tasks_app_env_vars] + +# Handlers for serving the index page. +handlers: + - url: /static + static_dir: static + - url: / + static_files: index.html + upload: index.html +# [END cloud_tasks_app_yaml] diff --git a/cloud-tasks/app/createTask.js b/cloud-tasks/app/createTask.js new file mode 100644 index 0000000000..f66bf14382 --- /dev/null +++ b/cloud-tasks/app/createTask.js @@ -0,0 +1,91 @@ +/** + * Copyright 2019 Google LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +// [START cloud_tasks_app_create_task] +const MAX_SCHEDULE_LIMIT = 30 * 60 * 60 * 24; // Represents 30 days in seconds. + +const createHttpTaskWithToken = async function( + project = 'my-project-id', // Your GCP Project id + queue = 'my-queue', // Name of your Queue + location = 'us-central1', // The GCP region of your queue + url = 'https://example.com/taskhandler', // The full url path that the request will be sent to + email = '@.iam.gserviceaccount.com', // Cloud IAM service account + payload = 'Hello, World!', // The task HTTP request body + date = new Date() // Intended date to schedule task +) { + // Imports the Google Cloud Tasks library. + const {v2beta3} = require('@google-cloud/tasks'); + + // Instantiates a client. + const client = new v2beta3.CloudTasksClient(); + + // Construct the fully qualified queue name. + const parent = client.queuePath(project, location, queue); + + // Convert message to buffer. + const convertedPayload = JSON.stringify(payload); + const body = Buffer.from(convertedPayload).toString('base64'); + + const task = { + httpRequest: { + httpMethod: 'POST', + url, + oidcToken: { + serviceAccountEmail: email, + }, + headers: { + 'Content-Type': 'application/json', + }, + body, + }, + }; + + const convertedDate = new Date(date); + const currentDate = new Date(); + + // Schedule time can not be in the past. + if (convertedDate < currentDate) { + console.error('Scheduled date in the past.'); + } else if (convertedDate > currentDate) { + const date_diff_in_seconds = (convertedDate - currentDate) / 1000; + // Restrict schedule time to the 30 day maximum. + if (date_diff_in_seconds > MAX_SCHEDULE_LIMIT) { + console.error('Schedule time is over 30 day maximum.'); + } + // Construct future date in Unix time. + const date_in_seconds = + Math.min(date_diff_in_seconds, MAX_SCHEDULE_LIMIT) + Date.now() / 1000; + // Add schedule time to request in Unix time using Timestamp structure. + // https://googleapis.dev/nodejs/tasks/latest/google.protobuf.html#.Timestamp + task.scheduleTime = { + seconds: date_in_seconds, + }; + } + + try { + // Send create task request. + const [response] = await client.createTask({parent, task}); + console.log(`Created task ${response.name}`); + return response.name; + } catch (error) { + // Construct error for Stackdriver Error Reporting + console.error(Error(error.message)); + } +}; + +module.exports = createHttpTaskWithToken; +// [END cloud_tasks_app_create_task] diff --git a/cloud-tasks/app/index.html b/cloud-tasks/app/index.html new file mode 100644 index 0000000000..3e95d58fbc --- /dev/null +++ b/cloud-tasks/app/index.html @@ -0,0 +1,56 @@ + + + + + + Send a Postcard + + + +
+
+
+ To: +
+ From: +
+
+ Hello,
World! +
+
+ +
+
+ + +
+
+ + +
+ +
+
+ + + + diff --git a/cloud-tasks/app/index.js b/cloud-tasks/app/index.js new file mode 100644 index 0000000000..5ee01e6c12 --- /dev/null +++ b/cloud-tasks/app/index.js @@ -0,0 +1,55 @@ +/** + * Copyright 2019 Google LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const createHttpTaskWithToken = require('./createTask'); +const express = require('express'); + +const app = express(); + +const {QUEUE_NAME} = process.env; +const {QUEUE_LOCATION} = process.env; +const {FUNCTION_URL} = process.env; +const {SERVICE_ACCOUNT_EMAIL} = process.env; + +// Parse form inputs from /index.html. +app.use(express.urlencoded({extended: true})); + +// [START cloud_tasks_app] +app.post('/send-email', (req, res) => { + // Set the task payload to the form submission. + const {to_name, from_name, to_email, date} = req.body; + const payload = {to_name, from_name, to_email}; + + createHttpTaskWithToken( + process.env.GOOGLE_CLOUD_PROJECT, + QUEUE_NAME, + QUEUE_LOCATION, + FUNCTION_URL, + SERVICE_ACCOUNT_EMAIL, + payload, + date + ); + + res.status(202).send('📫 Your postcard is in the mail! 💌'); +}); +// [END cloud_tasks_app] + +const PORT = process.env.PORT || 8080; +app.listen(PORT, () => { + console.log(`App listening on port ${PORT}`); + console.log('Press Ctrl+C to quit.'); +}); diff --git a/cloud-tasks/app/package.json b/cloud-tasks/app/package.json new file mode 100644 index 0000000000..d7d8bbaddd --- /dev/null +++ b/cloud-tasks/app/package.json @@ -0,0 +1,18 @@ +{ + "name": "cloud-tasks-app", + "description": "Creates Cloud Tasks from form submission", + "main": "index.js", + "private": true, + "scripts": { + "start": "node index.js", + "test": "mocha" + }, + "dependencies": { + "@google-cloud/tasks": "^1.2.1", + "express": "^4.17.1" + }, + "devDependencies": { + "chai": "^4.2.0", + "mocha": "^6.1.3" + } +} diff --git a/cloud-tasks/app/static/index.css b/cloud-tasks/app/static/index.css new file mode 100644 index 0000000000..a2174f0adc --- /dev/null +++ b/cloud-tasks/app/static/index.css @@ -0,0 +1,84 @@ +.postcard { + margin: auto; + text-align: center; + position: relative; + width: 600px; + height: 400px; + background: #4285F4; + box-shadow: 0px 7px 20px 0px rgba(0, 0, 0, 0.5); +} + +.postcard::after { + content: ''; + position: absolute; + left: 30px; + top: 30px; + bottom: 30px; + right: 30px; + border: 5px solid #FFF; +} + +.postcard-text { + font-family: Arial, sans-serif; + font-size: 60px; + font-weight: bold; + text-transform: uppercase; + color: #FFF; + background: #4285F4; + position: absolute; + left: 0; + padding-left: 27px; + bottom: 0; + padding-bottom: 15px; + z-index: 2; +} + +.postcard-names { + font-family: Monaco, monospace; + font-size: 40px; + text-align: right; + color: #FFF; + background: #4285F4; + position: absolute; + right: 0; + top: 0; + padding: 15px 27px; + z-index: 2; + max-width: 546px; + max-height: 300px; + overflow: hidden; +} + +.info { + margin: auto; + text-align: center; + position: relative; + margin-top: 3em; + font-family: Arial, sans-serif; +} + +.info-line { + margin: 1em; +} + +.postcard-names input { + font-family: Monaco, monospace; + font-size: 36px; + color: #FFF; + background: #4285F4; + border-bottom: 3px solid #FFF; + width: 200px; +} + +.info-line input, label { + font-family: Monaco, monospace; + font-size: 24px; +} + +button { + font-family: Monaco, monospace; + font-size: 24px; + color: #FFF; + text-transform: uppercase; + background: #4285F4; +} diff --git a/cloud-tasks/app/test/create_task.test.js b/cloud-tasks/app/test/create_task.test.js new file mode 100644 index 0000000000..9b7be29f0d --- /dev/null +++ b/cloud-tasks/app/test/create_task.test.js @@ -0,0 +1,45 @@ +/** + * Copyright 2019 Google LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const createHttpTaskWithToken = require('../createTask'); +const {assert} = require('chai'); + +const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT; +const QUEUE_ID = 'default'; +const LOCATION_ID = 'us-central1'; +const SERVICE_ACCOUNT = + 'test-invoker@nodejs-docs-samples-tests.iam.gserviceaccount.com'; // Service account for test project. +const URL = 'https://example.com/'; // Fake endpoint that returns status 200. + +describe('Cloud Task Sample Tests', () => { + it('should create a task', async () => { + const date = new Date(); + const response = await createHttpTaskWithToken( + PROJECT_ID, + QUEUE_ID, + LOCATION_ID, + URL, + SERVICE_ACCOUNT, + date + ); + + const regex_output = new RegExp( + `projects/${PROJECT_ID}/locations/${LOCATION_ID}/queues/${QUEUE_ID}/tasks/` + ); + assert.match(response, regex_output); + }); +}); diff --git a/cloud-tasks/function/index.js b/cloud-tasks/function/index.js new file mode 100644 index 0000000000..a15040e614 --- /dev/null +++ b/cloud-tasks/function/index.js @@ -0,0 +1,135 @@ +/** + * Copyright 2019 Google LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +// [START cloud_tasks_func] +const sendgrid = require('@sendgrid/mail'); + +/** + * Responds to an HTTP request from Cloud Tasks and sends an email using data + * from the request body. + * + * @param {object} req Cloud Function request context. + * @param {object} req.body The request payload. + * @param {string} req.body.to_email Email address of the recipient. + * @param {string} req.body.to_name Name of the recipient. + * @param {string} req.body.from_name Name of the sender. + * @param {object} res Cloud Function response context. + */ +exports.sendEmail = async (req, res) => { + // Get the SendGrid API key from the environment variable. + const key = process.env.SENDGRID_API_KEY; + if (!key) { + const error = new Error( + 'SENDGRID_API_KEY was not provided as environment variable.' + ); + error.code = 401; + throw error; + } + sendgrid.setApiKey(key); + + // Get the body from the Cloud Task request. + const {to_email, to_name, from_name} = req.body; + if (!to_email) { + const error = new Error('Email address not provided.'); + error.code = 400; + throw error; + } else if (!to_name) { + const error = new Error('Recipient name not provided.'); + error.code = 400; + throw error; + } else if (!from_name) { + const error = new Error('Sender name not provided.'); + error.code = 400; + throw error; + } + + // Construct the email request. + const msg = { + to: to_email, + from: 'postcard@example.com', + subject: 'A Postcard Just for You!', + html: postcardHTML(to_name, from_name), + }; + + try { + await sendgrid.send(msg); + // Send OK to Cloud Task queue to delete task. + res.status(200).send('Postcard Sent!'); + } catch (error) { + // Any status code other than 2xx or 503 will trigger the task to retry. + res.status(error.code).send(error.message); + } +}; +// [END cloud_tasks_func] + +// Function creates an HTML postcard with message. +const postcardHTML = function(to_name, from_name) { + return ` + + + + +
+
+ To: ${to_name} +
+ From: ${from_name} +
+
+ Hello,
World! +
+ +
+ +`; +}; diff --git a/cloud-tasks/function/package.json b/cloud-tasks/function/package.json new file mode 100644 index 0000000000..0bf18c9caf --- /dev/null +++ b/cloud-tasks/function/package.json @@ -0,0 +1,21 @@ +{ + "name": "email-function", + "description": "Cloud Function sends an email when triggered by Cloud Task", + "main": "index.js", + "private": true, + "engines": { + "node": ">=8.0.0" + }, + "scripts": { + "test": "mocha test/*.test.js --timeout=60000" + }, + "dependencies": { + "@sendgrid/mail": "^6.4.0" + }, + "devDependencies": { + "@google-cloud/nodejs-repo-tools": "^3.3.0", + "mocha": "^6.0.0", + "proxyquire": "^2.1.0", + "sinon": "^7.0.0" + } +} diff --git a/cloud-tasks/function/test/index.test.js b/cloud-tasks/function/test/index.test.js new file mode 100644 index 0000000000..6989ace1cd --- /dev/null +++ b/cloud-tasks/function/test/index.test.js @@ -0,0 +1,156 @@ +/** + * Copyright 2019 Google LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const proxyquire = require('proxyquire').noCallThru(); +const sinon = require('sinon'); +const assert = require('assert'); + +const key = process.env.SENDGRID_API_KEY; + +const getSample = function() { + const requestPromise = sinon + .stub() + .returns(new Promise(resolve => resolve('test'))); + + return { + program: proxyquire('../', { + 'request-promise': requestPromise, + }), + mocks: { + requestPromise: requestPromise, + }, + }; +}; + +const getMocks = function() { + const req = { + body: {}, + }; + + const res = { + send: sinon.stub().returnsThis(), + status: function(statusCode) { + this.statusCode = statusCode; + return this; + }, + }; + sinon.spy(res, 'status'); + return {req, res}; +}; + +beforeEach(() => { + sinon.spy(console, 'error'); + sinon.spy(console, 'log'); +}); +afterEach(() => { + console.error.restore(); + console.log.restore(); +}); + +it('send fails without API key', async () => { + process.env = { + SENDGRID_API_KEY: undefined, + }; + const mocks = getMocks(); + const sample = getSample(); + const error = new Error( + 'SENDGRID_API_KEY was not provided as environment variable.' + ); + error.code = 401; + + try { + await sample.program.sendEmail(mocks.req, mocks.res); + } catch (err) { + assert.deepStrictEqual(err, error); + } +}); + +it('send fails without sender name', async () => { + process.env = { + SENDGRID_API_KEY: key, + }; + const mocks = getMocks(); + mocks.req.body.to_email = 'to@gmail.com'; + mocks.req.body.to_name = 'testA'; + + const sample = getSample(); + const error = new Error('Sender name not provided.'); + error.code = 400; + + try { + await sample.program.sendEmail(mocks.req, mocks.res); + } catch (err) { + assert.deepStrictEqual(err, error); + } +}); + +it('send fails without recipient email', async () => { + process.env = { + SENDGRID_API_KEY: key, + }; + const mocks = getMocks(); + mocks.req.body.to_name = 'testA'; + mocks.req.body.from_name = 'testB'; + + const sample = getSample(); + const error = new Error('Email address not provided.'); + error.code = 400; + + try { + await sample.program.sendEmail(mocks.req, mocks.res); + } catch (err) { + assert.deepStrictEqual(err, error); + } +}); + +it('send fails without recipient name', async () => { + process.env = { + SENDGRID_API_KEY: key, + }; + const mocks = getMocks(); + mocks.req.body.to_email = 'to@gmail.com'; + mocks.req.body.from_name = 'testB'; + + const sample = getSample({ + SENDGRID_API_KEY: key, + }); + + const error = new Error('Recipient name not provided.'); + error.code = 400; + + try { + await sample.program.sendEmail(mocks.req, mocks.res); + } catch (err) { + assert.deepStrictEqual(err, error); + } +}); + +it('send succeeds', async () => { + process.env = { + SENDGRID_API_KEY: key, + }; + const mocks = getMocks(); + mocks.req.body.to_email = 'to@gmail.com'; + mocks.req.body.to_name = 'testA'; + mocks.req.body.from_name = 'testB'; + + const sample = getSample(); + await sample.program.sendEmail(mocks.req, mocks.res); + assert.strictEqual(mocks.res.status.callCount, 1); + assert.deepStrictEqual(mocks.res.status.firstCall.args, [200]); + assert.strictEqual(mocks.res.send.callCount, 1); +});