From 8b7d427d6638ca00aa6ae991ea61eacf073ee0e0 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Fri, 30 Jul 2021 13:24:50 -0700 Subject: [PATCH 1/2] Add Pub/Sub support + HTTPS testing While adding Pub/Sub support I noticed that there were now v2/ tests. Added those tests + fixes that they uncovered. Also fixed issue where 'firebase-functions/logger' wasn't working as an import in my test project. --- package.json | 17 +- spec/v1/providers/https.spec.ts | 2 - spec/v2/providers/https.spec.ts | 386 +++++++++++++++++++++++++++++++ spec/v2/providers/pubsub.spec.ts | 142 ++++++++++++ src/v1/providers/pubsub.ts | 4 +- src/v2/base.ts | 36 +++ src/v2/index.ts | 5 +- src/v2/options.ts | 5 +- src/v2/providers/https.ts | 36 +-- src/v2/providers/pubsub.ts | 158 +++++++++++++ 10 files changed, 769 insertions(+), 22 deletions(-) create mode 100644 spec/v2/providers/https.spec.ts create mode 100644 spec/v2/providers/pubsub.spec.ts create mode 100644 src/v2/providers/pubsub.ts diff --git a/package.json b/package.json index 668947f78..1ba035809 100644 --- a/package.json +++ b/package.json @@ -31,13 +31,18 @@ "./lib/logger": "./lib/logger/index.js", "./lib/logger/compat": "./lib/logger/compat.js", "./v2": "./lib/v2/index.js", + "./v2/base": "./lib/v2/base.js", "./v2/options": "./lib/v2/options.js", - "./v2/https": "./lib/v2/providers/https.js" + "./v2/https": "./lib/v2/providers/https.js", + "./v2/pubsub": "./lib/v2/providers/pubsub.js" }, "typesVersions": { "*": { - "logger/*": [ - "lib/logger/*" + "logger": [ + "lib/logger" + ], + "logger/compat": [ + "lib/logger/compat" ], "v1": [ "lib/v1" @@ -45,11 +50,17 @@ "v2": [ "lib/v2" ], + "v2/base": [ + "lib/v2/base" + ], "v2/options": [ "lib/v2/options" ], "v2/https": [ "lib/v2/providers/https" + ], + "v2/pubsub": [ + "lib/v2/providers/pubsub" ] } }, diff --git a/spec/v1/providers/https.spec.ts b/spec/v1/providers/https.spec.ts index f41ae00fe..4e5d2846f 100644 --- a/spec/v1/providers/https.spec.ts +++ b/spec/v1/providers/https.spec.ts @@ -30,8 +30,6 @@ import { MockRequest, } from '../../fixtures/mockrequest'; -// TODO(inlined) dedup this between v1, common, etc. - /** * RunHandlerResult contains the data from an express.Response. */ diff --git a/spec/v2/providers/https.spec.ts b/spec/v2/providers/https.spec.ts new file mode 100644 index 000000000..f7ab52a8c --- /dev/null +++ b/spec/v2/providers/https.spec.ts @@ -0,0 +1,386 @@ +import { expect } from 'chai'; +import * as express from 'express'; + +import * as options from '../../../src/v2/options'; +import * as https from '../../../src/v2/providers/https'; +import { + expectedResponseHeaders, + MockRequest, +} from '../../fixtures/mockrequest'; + +/** + * RunHandlerResult contains the data from an express.Response. + */ +interface RunHandlerResult { + status: number; + headers: { [name: string]: string }; + body: any; +} + +function runHandler( + handler: express.Handler, + request: https.Request +): Promise { + return new Promise((resolve, reject) => { + // MockResponse mocks an express.Response. + // This class lives here so it can reference resolve and reject. + class MockResponse { + private statusCode = 0; + private headers: { [name: string]: string } = {}; + + public status(code: number) { + this.statusCode = code; + return this; + } + + // Headers are only set by the cors handler. + public setHeader(name: string, value: string) { + this.headers[name] = value; + } + + public getHeader(name: string): string { + return this.headers[name]; + } + + public send(body: any) { + resolve({ + status: this.statusCode, + headers: this.headers, + body, + }); + } + + public end() { + this.send(undefined); + } + } + + const response = new MockResponse(); + handler(request, response as any, () => undefined); + }); +} + +describe('onRequest', () => { + beforeEach(() => { + options.setGlobalOptions({}); + process.env.GCLOUD_PROJECT = 'aProject'; + }); + + afterEach(() => { + delete process.env.GCLOUD_PROJECT; + }); + + it('should return a minimal trigger with appropriate values', () => { + const result = https.onRequest((req, res) => { + res.send(200); + }); + expect(result.__trigger).to.deep.equal({ + apiVersion: 2, + platform: 'gcfv2', + httpsTrigger: { + allowInsecure: false, + }, + labels: {}, + }); + }); + + it('should create a complex trigger with appropraite values', () => { + const result = https.onRequest( + { + region: ['us-west1', 'us-central1'], + memory: '512MB', + timeoutSeconds: 60, + minInstances: 1, + maxInstances: 3, + concurrency: 20, + vpcConnector: 'aConnector', + vpcConnectorEgressSettings: 'ALL_TRAFFIC', + serviceAccount: 'root@', + ingressSettings: 'ALLOW_ALL', + labels: { + hello: 'world', + }, + }, + (req, res) => { + res.send(200); + } + ); + expect(result.__trigger).to.deep.equal({ + apiVersion: 2, + platform: 'gcfv2', + httpsTrigger: { + allowInsecure: false, + }, + regions: ['us-west1', 'us-central1'], + availableMemoryMb: 512, + timeout: '60s', + minInstances: 1, + maxInstances: 3, + concurrency: 20, + vpcConnector: 'aConnector', + vpcConnectorEgressSettings: 'ALL_TRAFFIC', + serviceAccountEmail: 'root@aProject.iam.gserviceaccount.com', + ingressSettings: 'ALLOW_ALL', + labels: { + hello: 'world', + }, + }); + }); + + it('should merge options and globalOptions', () => { + options.setGlobalOptions({ + concurrency: 20, + region: 'europe-west1', + minInstances: 1, + }); + + const result = https.onRequest( + { + region: ['us-west1', 'us-central1'], + minInstances: 3, + }, + (req, res) => { + res.send(200); + } + ); + + expect(result.__trigger).to.deep.equal({ + apiVersion: 2, + platform: 'gcfv2', + httpsTrigger: { + allowInsecure: false, + }, + concurrency: 20, + minInstances: 3, + regions: ['us-west1', 'us-central1'], + labels: {}, + }); + }); + + it('should be an express handler', async () => { + const func = https.onRequest((req, res) => { + res.send('Works'); + }); + + const req = new MockRequest( + { + data: {}, + }, + { + 'content-type': 'application/json', + origin: 'example.com', + } + ); + req.method = 'POST'; + + const resp = await runHandler(func, req as any); + expect(resp.body).to.equal('Works'); + }); + + it('should enforce CORS options', async () => { + const func = https.onRequest({ cors: 'example.com' }, (req, res) => { + throw new Error('Should not reach here for OPTIONS preflight'); + }); + + const req = new MockRequest( + { + data: {}, + }, + { + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'origin', + Origin: 'example.com', + } + ); + req.method = 'OPTIONS'; + + const resp = await runHandler(func, req as any); + expect(resp.status).to.equal(204); + expect(resp.body).to.be.undefined; + expect(resp.headers).to.deep.equal({ + 'Access-Control-Allow-Methods': 'GET,HEAD,PUT,PATCH,POST,DELETE', + 'Access-Control-Allow-Origin': 'example.com', + 'Content-Length': '0', + Vary: 'Origin, Access-Control-Request-Headers', + }); + }); +}); + +describe('onCall', () => { + beforeEach(() => { + options.setGlobalOptions({}); + process.env.GCLOUD_PROJECT = 'aProject'; + }); + + afterEach(() => { + delete process.env.GCLOUD_PROJECT; + }); + + it('should return a minimal trigger with appropriate values', () => { + const result = https.onCall((data, context) => 42); + expect(result.__trigger).to.deep.equal({ + apiVersion: 2, + platform: 'gcfv2', + httpsTrigger: { + allowInsecure: false, + }, + labels: { + 'deployment-callable': 'true', + }, + }); + }); + + it('should create a complex trigger with appropraite values', () => { + const result = https.onCall( + { + region: 'us-west1', + memory: '512MB', + timeoutSeconds: 60, + minInstances: 1, + maxInstances: 3, + concurrency: 20, + vpcConnector: 'aConnector', + vpcConnectorEgressSettings: 'ALL_TRAFFIC', + serviceAccount: 'root@', + ingressSettings: 'ALLOW_ALL', + labels: { + hello: 'world', + }, + }, + (data, context) => 42 + ); + expect(result.__trigger).to.deep.equal({ + apiVersion: 2, + platform: 'gcfv2', + httpsTrigger: { + allowInsecure: false, + }, + regions: ['us-west1'], + availableMemoryMb: 512, + timeout: '60s', + minInstances: 1, + maxInstances: 3, + concurrency: 20, + vpcConnector: 'aConnector', + vpcConnectorEgressSettings: 'ALL_TRAFFIC', + serviceAccountEmail: 'root@aProject.iam.gserviceaccount.com', + ingressSettings: 'ALLOW_ALL', + labels: { + hello: 'world', + 'deployment-callable': 'true', + }, + }); + }); + + it('should merge options and globalOptions', () => { + options.setGlobalOptions({ + concurrency: 20, + region: 'europe-west1', + minInstances: 1, + }); + + const result = https.onCall( + { + region: ['us-west1', 'us-central1'], + minInstances: 3, + }, + (data, context) => 42 + ); + + expect(result.__trigger).to.deep.equal({ + apiVersion: 2, + platform: 'gcfv2', + httpsTrigger: { + allowInsecure: false, + }, + concurrency: 20, + minInstances: 3, + regions: ['us-west1', 'us-central1'], + labels: { + 'deployment-callable': 'true', + }, + }); + }); + + it('has a .run method', () => { + const cf = https.onCall((d, c) => { + return { data: d, context: c }; + }); + + const data = 'data'; + const context: any = { + instanceIdToken: 'token', + auth: { + uid: 'abc', + token: 'token', + }, + }; + expect(cf.run(data, context)).to.deep.equal({ data, context }); + }); + + it('should be an express handler', async () => { + const func = https.onCall((data, context) => 42); + + const req = new MockRequest( + { + data: {}, + }, + { + 'content-type': 'application/json', + origin: 'example.com', + } + ); + req.method = 'POST'; + + const resp = await runHandler(func, req as any); + expect(resp.body).to.deep.equal({ result: 42 }); + }); + + it('should enforce CORS options', async () => { + const func = https.onCall({ cors: 'example.com' }, (req, res) => { + throw new Error('Should not reach here for OPTIONS preflight'); + }); + + const req = new MockRequest( + { + data: {}, + }, + { + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'origin', + Origin: 'example.com', + } + ); + req.method = 'OPTIONS'; + + const resp = await runHandler(func, req as any); + expect(resp.status).to.equal(204); + expect(resp.body).to.be.undefined; + expect(resp.headers).to.deep.equal({ + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Origin': 'example.com', + 'Content-Length': '0', + Vary: 'Origin, Access-Control-Request-Headers', + }); + }); + + it('adds CORS headers', async () => { + const func = https.onCall((data, context) => 42); + const req = new MockRequest( + { + data: {}, + }, + { + 'content-type': 'application/json', + origin: 'example.com', + } + ); + req.method = 'POST'; + + const response = await runHandler(func, req as any); + + expect(response.status).to.equal(200); + expect(response.body).to.be.deep.equal({ result: 42 }); + expect(response.headers).to.deep.equal(expectedResponseHeaders); + }); +}); diff --git a/spec/v2/providers/pubsub.spec.ts b/spec/v2/providers/pubsub.spec.ts new file mode 100644 index 000000000..1bddbd31c --- /dev/null +++ b/spec/v2/providers/pubsub.spec.ts @@ -0,0 +1,142 @@ +import { expect } from 'chai'; + +import { CloudEvent } from '../../../src/v2/base'; +import * as options from '../../../src/v2/options'; +import * as pubsub from '../../../src/v2/providers/pubsub'; + +const EVENT_TRIGGER = { + eventType: 'google.cloud.pubsub.topic.v1.messagePublished', + resource: 'projects/aProject/topics/topic', +}; + +describe('onMessagePublished', () => { + beforeEach(() => { + options.setGlobalOptions({}); + process.env.GCLOUD_PROJECT = 'aProject'; + }); + + afterEach(() => { + delete process.env.GCLOUD_PROJECT; + }); + + it('should return a minimal trigger with appropriate values', () => { + const result = pubsub.onMessagePublished('topic', () => 42); + expect(result.__trigger).to.deep.equal({ + apiVersion: 2, + platform: 'gcfv2', + eventTrigger: EVENT_TRIGGER, + labels: {}, + }); + }); + + it('should create a complex trigger with appropraite values', () => { + const result = pubsub.onMessagePublished( + { + topic: 'topic', + region: 'us-west1', + memory: '512MB', + timeoutSeconds: 60, + minInstances: 1, + maxInstances: 3, + concurrency: 20, + vpcConnector: 'aConnector', + vpcConnectorEgressSettings: 'ALL_TRAFFIC', + serviceAccount: 'root@', + ingressSettings: 'ALLOW_ALL', + labels: { + hello: 'world', + }, + }, + () => 42 + ); + expect(result.__trigger).to.deep.equal({ + apiVersion: 2, + platform: 'gcfv2', + eventTrigger: EVENT_TRIGGER, + regions: ['us-west1'], + availableMemoryMb: 512, + timeout: '60s', + minInstances: 1, + maxInstances: 3, + concurrency: 20, + vpcConnector: 'aConnector', + vpcConnectorEgressSettings: 'ALL_TRAFFIC', + serviceAccountEmail: 'root@aProject.iam.gserviceaccount.com', + ingressSettings: 'ALLOW_ALL', + labels: { + hello: 'world', + }, + }); + }); + + it('should merge options and globalOptions', () => { + options.setGlobalOptions({ + concurrency: 20, + region: 'europe-west1', + minInstances: 1, + }); + + const result = pubsub.onMessagePublished( + { + topic: 'topic', + region: 'us-west1', + minInstances: 3, + }, + () => 42 + ); + + expect(result.__trigger).to.deep.equal({ + apiVersion: 2, + platform: 'gcfv2', + concurrency: 20, + minInstances: 3, + regions: ['us-west1'], + labels: {}, + eventTrigger: EVENT_TRIGGER, + }); + }); + + it('should have a .run method', () => { + const func = pubsub.onMessagePublished('topic', (event) => event); + + const res = func.run('input' as any); + + expect(res).to.equal('input'); + }); + + it('should parse pubsub messages', () => { + let json: unknown; + const messageJSON = { + messageId: 'uuid', + data: Buffer.from(JSON.stringify({ hello: 'world' })).toString('base64'), + attributes: { key: 'value' }, + orderingKey: 'orderingKey', + publishTime: new Date(Date.now()).toISOString(), + }; + const publishData: pubsub.MessagePublishedData = { + message: messageJSON as any, + subscription: 'projects/aProject/subscriptions/aSubscription', + }; + const event: CloudEvent = { + specversion: '1.0', + source: '//pubsub.googleapis.com/projects/aProject/topics/topic', + id: 'uuid', + type: EVENT_TRIGGER.eventType, + time: messageJSON.publishTime, + data: publishData, + }; + + const func = pubsub.onMessagePublished('topic', (event) => { + json = event.data.message.json; + return event; + }); + + const eventAgain = func(event); + + // Deep equal uses JSON equality, so we'll still match even though + // Message is a class and we passed an interface. + expect(eventAgain).to.deep.equal(event); + + expect(json).to.deep.equal({ hello: 'world' }); + }); +}); diff --git a/src/v1/providers/pubsub.ts b/src/v1/providers/pubsub.ts index 264ef9480..9cd88bd86 100644 --- a/src/v1/providers/pubsub.ts +++ b/src/v1/providers/pubsub.ts @@ -206,7 +206,9 @@ export class Message { */ get json(): any { if (typeof this._json === 'undefined') { - this._json = JSON.parse(new Buffer(this.data, 'base64').toString('utf8')); + this._json = JSON.parse( + Buffer.from(this.data, 'base64').toString('utf8') + ); } return this._json; diff --git a/src/v2/base.ts b/src/v2/base.ts index c2d9a6fe4..3497f5dff 100644 --- a/src/v2/base.ts +++ b/src/v2/base.ts @@ -22,6 +22,9 @@ /** @internal */ export interface TriggerAnnotation { + concurrency?: number; + minInstances?: number; + maxInstances?: number; availableMemoryMb?: number; eventTrigger?: { eventType: string; @@ -40,3 +43,36 @@ export interface TriggerAnnotation { // TODO: schedule } + +/** + * A CloudEvent is a cross-platform format for encoding a serverless event. + * More information can be found in https://github.com/cloudevents/spec + */ +export interface CloudEvent { + /** Version of the CloudEvents spec for this event. */ + readonly specversion: '1.0'; + + /** A globally unique ID for this event. */ + id: string; + + /** The resource which published this event. */ + source: string; + + /** The type of event that this represents. */ + type: string; + + /** When this event occurred. */ + time: string; + + /** Information about this specific event. */ + data: T; +} + +/** A handler for CloudEvents. */ +export interface CloudFunction { + (raw: CloudEvent): any | Promise; + + __trigger: unknown; + + run(event: CloudEvent): any | Promise; +} diff --git a/src/v2/index.ts b/src/v2/index.ts index c850c54dd..e9d5f3c67 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -22,7 +22,10 @@ import * as logger from '../logger'; import * as https from './providers/https'; +import * as pubsub from './providers/pubsub'; -export { https, logger }; +export { https, pubsub, logger }; export { setGlobalOptions, GlobalOptions } from './options'; + +export { CloudFunction, CloudEvent } from './base'; diff --git a/src/v2/options.ts b/src/v2/options.ts index e3c16ff84..bc3fd6ccf 100644 --- a/src/v2/options.ts +++ b/src/v2/options.ts @@ -222,6 +222,9 @@ export function optionsToTriggerAnnotations( copyIfPresent( annotation, opts, + 'concurrency', + 'minInstances', + 'maxInstances', 'ingressSettings', 'labels', 'vpcConnector', @@ -237,7 +240,7 @@ export function optionsToTriggerAnnotations( } ); convertIfPresent(annotation, opts, 'regions', 'region', (region) => { - if (typeof 'region' === 'string') { + if (typeof region === 'string') { return [region]; } return region; diff --git a/src/v2/providers/https.ts b/src/v2/providers/https.ts index 7faa9c6c1..d0abba835 100644 --- a/src/v2/providers/https.ts +++ b/src/v2/providers/https.ts @@ -26,7 +26,7 @@ import * as express from 'express'; import * as common from '../../common/providers/https'; import * as options from '../options'; -type Request = common.Request; +export type Request = common.Request; export type CallableContext = common.CallableContext; export type FunctionsErrorCode = common.FunctionsErrorCode; @@ -44,12 +44,16 @@ export type HttpsHandler = ( request: Request, response: express.Response ) => void | Promise; -export type CallableHandler = ( - data: any, +export type CallableHandler = ( + data: T, context: CallableContext ) => any | Promise; export type HttpsFunction = HttpsHandler & { __trigger: unknown }; +export interface CallableFunction extends HttpsHandler { + __trigger: unknown; + run(data: T, context: CallableContext): any | Promise; +} export function onRequest( opts: HttpsOptions, @@ -106,25 +110,27 @@ export function onRequest( return handler as HttpsFunction; } -export function onCall( +export function onCall( opts: HttpsOptions, - handler: CallableHandler -): HttpsFunction; -export function onCall(handler: CallableHandler): HttpsFunction; -export function onCall( - optsOrHandler: HttpsOptions | CallableHandler, - handler?: CallableHandler -): HttpsFunction { + handler: CallableHandler +): CallableFunction; +export function onCall( + handler: CallableHandler +): CallableFunction; +export function onCall( + optsOrHandler: HttpsOptions | CallableHandler, + handler?: CallableHandler +): CallableFunction { let opts: HttpsOptions; if (arguments.length == 1) { opts = {}; - handler = optsOrHandler as CallableHandler; + handler = optsOrHandler as CallableHandler; } else { opts = optsOrHandler as HttpsOptions; } const origin = 'cors' in opts ? opts.cors : true; - const func = common.onCallHandler({ origin, methods: 'POST' }, handler); + const func: any = common.onCallHandler({ origin, methods: 'POST' }, handler); Object.defineProperty(func, '__trigger', { get: () => { @@ -154,5 +160,7 @@ export function onCall( }; }, }); - return func as HttpsFunction; + + func.run = handler; + return func; } diff --git a/src/v2/providers/pubsub.ts b/src/v2/providers/pubsub.ts new file mode 100644 index 000000000..a0cfcf980 --- /dev/null +++ b/src/v2/providers/pubsub.ts @@ -0,0 +1,158 @@ +import { CloudEvent, CloudFunction } from '../base'; +import * as options from '../options'; + +/** + * Interface representing a Google Cloud Pub/Sub message. + * + * @param data Payload of a Pub/Sub message. + */ +export class Message { + /** + * Autogenerated ID that uniquely identifies this message. + */ + readonly messageId: string; + + /** + * Time the message was published + */ + readonly publishTime: string; + + /** + * The data payload of this message object as a base64-encoded string. + */ + readonly data: string; + + /** + * User-defined attributes published with the message, if any. + */ + readonly attributes: { [key: string]: string }; + + /** + * User-defined key used to ensure ordering amongst messages with the same key. + */ + readonly orderingKey: string; + + /** @hidden */ + private _json: any; + + constructor(data: any) { + this.messageId = data.messageId; + this.data = data.data; + (this.attributes = data.attributes || {}), (this._json = data.json); + this.orderingKey = data.orderingKey; + this.publishTime = data.publishTime || new Date().toISOString(); + } + + /** + * The JSON data payload of this message object, if any. + */ + get json(): any { + if (typeof this._json === 'undefined') { + this._json = JSON.parse( + Buffer.from(this.data, 'base64').toString('utf8') + ); + } + + return this._json; + } + + /** + * Returns a JSON-serializable representation of this object. + * + * @return A JSON-serializable representation of this object. + */ + toJSON(): any { + const json: Record = { + messageId: this.messageId, + data: this.data, + publishTime: this.publishTime, + }; + if (Object.keys(this.attributes).length) { + json.attributes = this.attributes; + } + if (this.orderingKey) { + json.orderingKey = this.orderingKey; + } + return json; + } +} + +/** The interface published in a Pub/Sub publish subscription. */ +export interface MessagePublishedData { + readonly message: Message; + readonly subscription: string; +} + +/** PubSubOptions extend EventHandlerOptions but must include a topic. */ +export interface PubSubOptions extends options.EventHandlerOptions { + topic: string; +} + +/** Handle a message being published to a Pub/Sub topic. */ +export function onMessagePublished( + topic: string, + callback: (event: CloudEvent) => any | Promise +): CloudFunction; + +/** Handle a message being published to a Pub/Sub topic. */ +export function onMessagePublished( + options: PubSubOptions, + callback: (event: CloudEvent) => any | Promise +): CloudFunction; + +export function onMessagePublished( + topicOrOptions: string | PubSubOptions, + callback: (event: CloudEvent) => any | Promise +): CloudFunction { + let topic: string; + let opts: options.EventHandlerOptions; + if (typeof topicOrOptions === 'string') { + topic = topicOrOptions; + opts = {}; + } else { + topic = topicOrOptions.topic; + opts = { ...topicOrOptions }; + delete (opts as any).topic; + } + + const func = (raw: CloudEvent) => { + const messagePublisheData = raw.data as { + message: unknown; + subscription: string; + }; + messagePublisheData.message = new Message(messagePublisheData.message); + return callback(raw as CloudEvent); + }; + + func.run = callback; + + func.__trigger = 'silencing transpiler'; + + Object.defineProperty(func, '__trigger', { + get: () => { + const baseOpts = options.optionsToTriggerAnnotations( + options.getGlobalOptions() + ); + const specificOpts = options.optionsToTriggerAnnotations(opts); + + return { + // TODO(inlined): Remove "apiVersion" once the CLI has migrated to + // "platform" + apiVersion: 2, + platform: 'gcfv2', + ...baseOpts, + ...specificOpts, + labels: { + ...baseOpts?.labels, + ...specificOpts?.labels, + }, + eventTrigger: { + eventType: 'google.cloud.pubsub.topic.v1.messagePublished', + resource: `projects/${process.env.GCLOUD_PROJECT}/topics/${topic}`, + }, + }; + }, + }); + + return func; +} From 8226a891157e93e4b9024f5432329123d977d873 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Fri, 30 Jul 2021 16:08:13 -0700 Subject: [PATCH 2/2] PR feedback --- package.json | 2 +- spec/v2/providers/helpers.ts | 35 +++++++++++++++++ spec/v2/providers/https.spec.ts | 67 ++++---------------------------- spec/v2/providers/pubsub.spec.ts | 42 ++++---------------- src/v2/{base.ts => core.ts} | 17 ++++++++ src/v2/index.ts | 2 +- src/v2/options.ts | 2 +- src/v2/providers/https.ts | 30 +++++++------- src/v2/providers/pubsub.ts | 60 ++++++++++++++++------------ 9 files changed, 120 insertions(+), 137 deletions(-) create mode 100644 spec/v2/providers/helpers.ts rename src/v2/{base.ts => core.ts} (78%) diff --git a/package.json b/package.json index 1ba035809..50e55574d 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "./lib/logger": "./lib/logger/index.js", "./lib/logger/compat": "./lib/logger/compat.js", "./v2": "./lib/v2/index.js", - "./v2/base": "./lib/v2/base.js", + "./v2/core": "./lib/v2/core.js", "./v2/options": "./lib/v2/options.js", "./v2/https": "./lib/v2/providers/https.js", "./v2/pubsub": "./lib/v2/providers/pubsub.js" diff --git a/spec/v2/providers/helpers.ts b/spec/v2/providers/helpers.ts new file mode 100644 index 000000000..a654a4944 --- /dev/null +++ b/spec/v2/providers/helpers.ts @@ -0,0 +1,35 @@ +import * as options from '../../../src/v2/options'; + +export const FULL_OPTIONS: options.GlobalOptions = { + region: 'us-west1', + memory: '512MB', + timeoutSeconds: 60, + minInstances: 1, + maxInstances: 3, + concurrency: 20, + vpcConnector: 'aConnector', + vpcConnectorEgressSettings: 'ALL_TRAFFIC', + serviceAccount: 'root@', + ingressSettings: 'ALLOW_ALL', + labels: { + hello: 'world', + }, +}; + +export const FULL_TRIGGER = { + apiVersion: 2, + platform: 'gcfv2', + regions: ['us-west1'], + availableMemoryMb: 512, + timeout: '60s', + minInstances: 1, + maxInstances: 3, + concurrency: 20, + vpcConnector: 'aConnector', + vpcConnectorEgressSettings: 'ALL_TRAFFIC', + serviceAccountEmail: 'root@aProject.iam.gserviceaccount.com', + ingressSettings: 'ALLOW_ALL', + labels: { + hello: 'world', + }, +}; diff --git a/spec/v2/providers/https.spec.ts b/spec/v2/providers/https.spec.ts index f7ab52a8c..7103372a0 100644 --- a/spec/v2/providers/https.spec.ts +++ b/spec/v2/providers/https.spec.ts @@ -7,6 +7,7 @@ import { expectedResponseHeaders, MockRequest, } from '../../fixtures/mockrequest'; +import { FULL_OPTIONS, FULL_TRIGGER } from './helpers'; /** * RunHandlerResult contains the data from an express.Response. @@ -84,46 +85,22 @@ describe('onRequest', () => { }); }); - it('should create a complex trigger with appropraite values', () => { + it('should create a complex trigger with appropriate values', () => { const result = https.onRequest( { + ...FULL_OPTIONS, region: ['us-west1', 'us-central1'], - memory: '512MB', - timeoutSeconds: 60, - minInstances: 1, - maxInstances: 3, - concurrency: 20, - vpcConnector: 'aConnector', - vpcConnectorEgressSettings: 'ALL_TRAFFIC', - serviceAccount: 'root@', - ingressSettings: 'ALLOW_ALL', - labels: { - hello: 'world', - }, }, (req, res) => { res.send(200); } ); expect(result.__trigger).to.deep.equal({ - apiVersion: 2, - platform: 'gcfv2', + ...FULL_TRIGGER, httpsTrigger: { allowInsecure: false, }, regions: ['us-west1', 'us-central1'], - availableMemoryMb: 512, - timeout: '60s', - minInstances: 1, - maxInstances: 3, - concurrency: 20, - vpcConnector: 'aConnector', - vpcConnectorEgressSettings: 'ALL_TRAFFIC', - serviceAccountEmail: 'root@aProject.iam.gserviceaccount.com', - ingressSettings: 'ALLOW_ALL', - labels: { - hello: 'world', - }, }); }); @@ -230,43 +207,15 @@ describe('onCall', () => { }); }); - it('should create a complex trigger with appropraite values', () => { - const result = https.onCall( - { - region: 'us-west1', - memory: '512MB', - timeoutSeconds: 60, - minInstances: 1, - maxInstances: 3, - concurrency: 20, - vpcConnector: 'aConnector', - vpcConnectorEgressSettings: 'ALL_TRAFFIC', - serviceAccount: 'root@', - ingressSettings: 'ALLOW_ALL', - labels: { - hello: 'world', - }, - }, - (data, context) => 42 - ); + it('should create a complex trigger with appropriate values', () => { + const result = https.onCall(FULL_OPTIONS, (data, context) => 42); expect(result.__trigger).to.deep.equal({ - apiVersion: 2, - platform: 'gcfv2', + ...FULL_TRIGGER, httpsTrigger: { allowInsecure: false, }, - regions: ['us-west1'], - availableMemoryMb: 512, - timeout: '60s', - minInstances: 1, - maxInstances: 3, - concurrency: 20, - vpcConnector: 'aConnector', - vpcConnectorEgressSettings: 'ALL_TRAFFIC', - serviceAccountEmail: 'root@aProject.iam.gserviceaccount.com', - ingressSettings: 'ALLOW_ALL', labels: { - hello: 'world', + ...FULL_TRIGGER.labels, 'deployment-callable': 'true', }, }); diff --git a/spec/v2/providers/pubsub.spec.ts b/spec/v2/providers/pubsub.spec.ts index 1bddbd31c..f1669de7f 100644 --- a/spec/v2/providers/pubsub.spec.ts +++ b/spec/v2/providers/pubsub.spec.ts @@ -1,8 +1,9 @@ import { expect } from 'chai'; -import { CloudEvent } from '../../../src/v2/base'; +import { CloudEvent } from '../../../src/v2/core'; import * as options from '../../../src/v2/options'; import * as pubsub from '../../../src/v2/providers/pubsub'; +import { FULL_OPTIONS, FULL_TRIGGER } from './helpers'; const EVENT_TRIGGER = { eventType: 'google.cloud.pubsub.topic.v1.messagePublished', @@ -29,43 +30,14 @@ describe('onMessagePublished', () => { }); }); - it('should create a complex trigger with appropraite values', () => { + it('should create a complex trigger with appropriate values', () => { const result = pubsub.onMessagePublished( - { - topic: 'topic', - region: 'us-west1', - memory: '512MB', - timeoutSeconds: 60, - minInstances: 1, - maxInstances: 3, - concurrency: 20, - vpcConnector: 'aConnector', - vpcConnectorEgressSettings: 'ALL_TRAFFIC', - serviceAccount: 'root@', - ingressSettings: 'ALLOW_ALL', - labels: { - hello: 'world', - }, - }, + { ...FULL_OPTIONS, topic: 'topic' }, () => 42 ); expect(result.__trigger).to.deep.equal({ - apiVersion: 2, - platform: 'gcfv2', + ...FULL_TRIGGER, eventTrigger: EVENT_TRIGGER, - regions: ['us-west1'], - availableMemoryMb: 512, - timeout: '60s', - minInstances: 1, - maxInstances: 3, - concurrency: 20, - vpcConnector: 'aConnector', - vpcConnectorEgressSettings: 'ALL_TRAFFIC', - serviceAccountEmail: 'root@aProject.iam.gserviceaccount.com', - ingressSettings: 'ALLOW_ALL', - labels: { - hello: 'world', - }, }); }); @@ -113,11 +85,11 @@ describe('onMessagePublished', () => { orderingKey: 'orderingKey', publishTime: new Date(Date.now()).toISOString(), }; - const publishData: pubsub.MessagePublishedData = { + const publishData: pubsub.MessagePublishedData = { message: messageJSON as any, subscription: 'projects/aProject/subscriptions/aSubscription', }; - const event: CloudEvent = { + const event: CloudEvent> = { specversion: '1.0', source: '//pubsub.googleapis.com/projects/aProject/topics/topic', id: 'uuid', diff --git a/src/v2/base.ts b/src/v2/core.ts similarity index 78% rename from src/v2/base.ts rename to src/v2/core.ts index 3497f5dff..d2503739e 100644 --- a/src/v2/base.ts +++ b/src/v2/core.ts @@ -58,6 +58,9 @@ export interface CloudEvent { /** The resource which published this event. */ source: string; + /** The resource, provided by source, that this event relates to */ + subject?: string; + /** The type of event that this represents. */ type: string; @@ -66,6 +69,20 @@ export interface CloudEvent { /** Information about this specific event. */ data: T; + + /** + * A map of template parameter name to value for subject strings. + * + * This map is only available on some event types that allow templates + * in the subject string, such as Firestore. When listening to a document + * template "/users/{uid}", an event with subject "/documents/users/1234" + * would have a params of {"uid": "1234"}. + * + * Params are generated inside the firebase-functions SDK and are not + * part of the CloudEvents spec nor the payload that a Cloud Function + * actually receives. + */ + params?: Record; } /** A handler for CloudEvents. */ diff --git a/src/v2/index.ts b/src/v2/index.ts index e9d5f3c67..cce3ac1eb 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -28,4 +28,4 @@ export { https, pubsub, logger }; export { setGlobalOptions, GlobalOptions } from './options'; -export { CloudFunction, CloudEvent } from './base'; +export { CloudFunction, CloudEvent } from './core'; diff --git a/src/v2/options.ts b/src/v2/options.ts index bc3fd6ccf..f0f005366 100644 --- a/src/v2/options.ts +++ b/src/v2/options.ts @@ -26,7 +26,7 @@ import { } from '../common/encoding'; import { convertIfPresent, copyIfPresent } from '../common/encoding'; import * as logger from '../logger'; -import { TriggerAnnotation } from './base'; +import { TriggerAnnotation } from './core'; /** * List of all regions supported by Cloud Functions v2 diff --git a/src/v2/providers/https.ts b/src/v2/providers/https.ts index d0abba835..edfc4674a 100644 --- a/src/v2/providers/https.ts +++ b/src/v2/providers/https.ts @@ -44,15 +44,15 @@ export type HttpsHandler = ( request: Request, response: express.Response ) => void | Promise; -export type CallableHandler = ( +export type CallableHandler = ( data: T, context: CallableContext -) => any | Promise; +) => Ret; export type HttpsFunction = HttpsHandler & { __trigger: unknown }; -export interface CallableFunction extends HttpsHandler { +export interface CallableFunction extends HttpsHandler { __trigger: unknown; - run(data: T, context: CallableContext): any | Promise; + run(data: T, context: CallableContext): Ret; } export function onRequest( @@ -110,21 +110,21 @@ export function onRequest( return handler as HttpsFunction; } -export function onCall( +export function onCall>( opts: HttpsOptions, - handler: CallableHandler -): CallableFunction; -export function onCall( - handler: CallableHandler -): CallableFunction; -export function onCall( - optsOrHandler: HttpsOptions | CallableHandler, - handler?: CallableHandler -): CallableFunction { + handler: CallableHandler +): CallableFunction; +export function onCall>( + handler: CallableHandler +): CallableFunction; +export function onCall>( + optsOrHandler: HttpsOptions | CallableHandler, + handler?: CallableHandler +): CallableFunction { let opts: HttpsOptions; if (arguments.length == 1) { opts = {}; - handler = optsOrHandler as CallableHandler; + handler = optsOrHandler as CallableHandler; } else { opts = optsOrHandler as HttpsOptions; } diff --git a/src/v2/providers/pubsub.ts b/src/v2/providers/pubsub.ts index a0cfcf980..43ad15d57 100644 --- a/src/v2/providers/pubsub.ts +++ b/src/v2/providers/pubsub.ts @@ -1,4 +1,4 @@ -import { CloudEvent, CloudFunction } from '../base'; +import { CloudEvent, CloudFunction } from '../core'; import * as options from '../options'; /** @@ -6,7 +6,7 @@ import * as options from '../options'; * * @param data Payload of a Pub/Sub message. */ -export class Message { +export class Message { /** * Autogenerated ID that uniquely identifies this message. */ @@ -33,24 +33,31 @@ export class Message { readonly orderingKey: string; /** @hidden */ - private _json: any; + private _json: T; constructor(data: any) { this.messageId = data.messageId; this.data = data.data; - (this.attributes = data.attributes || {}), (this._json = data.json); - this.orderingKey = data.orderingKey; + this.attributes = data.attributes || {}; + this.orderingKey = data.orderingKey || ''; this.publishTime = data.publishTime || new Date().toISOString(); + this._json = data.json; } /** * The JSON data payload of this message object, if any. */ - get json(): any { + get json(): T { if (typeof this._json === 'undefined') { - this._json = JSON.parse( - Buffer.from(this.data, 'base64').toString('utf8') - ); + try { + this._json = JSON.parse( + Buffer.from(this.data, 'base64').toString('utf8') + ); + } catch (err) { + throw new Error( + `Unable to parse Pub/Sub message data as JSON: ${err.message}` + ); + } } return this._json; @@ -78,8 +85,8 @@ export class Message { } /** The interface published in a Pub/Sub publish subscription. */ -export interface MessagePublishedData { - readonly message: Message; +export interface MessagePublishedData { + readonly message: Message; readonly subscription: string; } @@ -89,21 +96,21 @@ export interface PubSubOptions extends options.EventHandlerOptions { } /** Handle a message being published to a Pub/Sub topic. */ -export function onMessagePublished( +export function onMessagePublished( topic: string, - callback: (event: CloudEvent) => any | Promise -): CloudFunction; + handler: (event: CloudEvent>) => any | Promise +): CloudFunction>; /** Handle a message being published to a Pub/Sub topic. */ -export function onMessagePublished( +export function onMessagePublished( options: PubSubOptions, - callback: (event: CloudEvent) => any | Promise -): CloudFunction; + handler: (event: CloudEvent>) => any | Promise +): CloudFunction>; -export function onMessagePublished( +export function onMessagePublished( topicOrOptions: string | PubSubOptions, - callback: (event: CloudEvent) => any | Promise -): CloudFunction { + handler: (event: CloudEvent>) => any | Promise +): CloudFunction> { let topic: string; let opts: options.EventHandlerOptions; if (typeof topicOrOptions === 'string') { @@ -116,17 +123,20 @@ export function onMessagePublished( } const func = (raw: CloudEvent) => { - const messagePublisheData = raw.data as { + const messagePublishedData = raw.data as { message: unknown; subscription: string; }; - messagePublisheData.message = new Message(messagePublisheData.message); - return callback(raw as CloudEvent); + messagePublishedData.message = new Message(messagePublishedData.message); + return handler(raw as CloudEvent>); }; - func.run = callback; + func.run = handler; - func.__trigger = 'silencing transpiler'; + // TypeScript doesn't recongize defineProperty as adding a property and complains + // that __trigger doesn't exist. We can either cast to any and lose all type safety + // or we can just assign a meaningless value before calling defineProperty. + func.__trigger = 'silence the transpiler'; Object.defineProperty(func, '__trigger', { get: () => {