diff --git a/package.json b/package.json index 099e5ec3f..ad1c392c7 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "./lib/providers/pubsub": "./lib/providers/pubsub.js", "./lib/providers/remoteConfig": "./lib/providers/remoteConfig.js", "./lib/providers/storage": "./lib/providers/storage.js", + "./lib/providers/tasks": "./lib/providers/tasks.js", "./lib/providers/testLab": "./lib/providers/testLab.js", "./v1": "./lib/index.js", "./v1/analytics": "./lib/providers/analytics.js", @@ -58,6 +59,7 @@ "./v2/params": "./lib/v2/params/index.js", "./v2/pubsub": "./lib/v2/providers/pubsub.js", "./v2/storage": "./lib/v2/providers/storage.js", + "./v2/tasks": "./lib/v2/providers/tasks.js", "./v2/alerts": "./lib/v2/providers/alerts/index.js", "./v2/alerts/appDistribution": "./lib/v2/providers/alerts/appDistribution.js", "./v2/alerts/billing": "./lib/v2/providers/alerts/billing.js", @@ -98,12 +100,27 @@ "v1/storage": [ "lib/providers/storage" ], + "v1/tasks": [ + "lib/providers/tasks" + ], "v1/testLab": [ "lib/providers/testLab" ], "v2": [ "lib/v2" ], + "v2/alerts": [ + "lib/v2/providers/alerts" + ], + "v2/alerts/appDistribution": [ + "lib/v2/providers/alerts/appDistribution" + ], + "v2/alerts/billing": [ + "lib/v2/providers/alerts/billing" + ], + "v2/alerts/crashlytics": [ + "lib/v2/providers/alerts/crashlytics" + ], "v2/base": [ "lib/v2/base" ], @@ -122,17 +139,8 @@ "v2/storage": [ "lib/v2/providers/storage" ], - "v2/alerts": [ - "lib/v2/providers/alerts" - ], - "v2/alerts/appDistribution": [ - "lib/v2/providers/alerts/appDistribution" - ], - "v2/alerts/billing": [ - "lib/v2/providers/alerts/billing" - ], - "v2/alerts/crashlytics": [ - "lib/v2/providers/alerts/crashlytics" + "v2/tasks": [ + "lib/v2/providers/tasks" ] } }, diff --git a/spec/common/providers/https.spec.ts b/spec/common/providers/https.spec.ts index d9d8e63c4..d1f8703bd 100644 --- a/spec/common/providers/https.spec.ts +++ b/spec/common/providers/https.spec.ts @@ -1,20 +1,14 @@ import { expect } from 'chai'; -import * as express from 'express'; import * as firebase from 'firebase-admin'; import * as sinon from 'sinon'; import { apps as appsNamespace } from '../../../src/apps'; -import * as debug from '../../../src/common/debug'; -import * as https from '../../../src/common/providers/https'; import { - CallableContext, - CallableRequest, - TaskContext, - TaskRequest, - unsafeDecodeAppCheckToken, - unsafeDecodeIdToken, -} from '../../../src/common/providers/https'; -import * as mocks from '../../fixtures/credential/key.json'; + checkAppCheckContext, + checkAuthContext, + runHandler, + RunHandlerResult, +} from '../../helper'; import { expectedResponseHeaders, generateAppCheckToken, @@ -25,15 +19,9 @@ import { mockFetchPublicKeys, mockRequest, } from '../../fixtures/mockrequest'; - -/** - * RunHandlerResult contains the data from an express.Response. - */ -interface RunHandlerResult { - status: number; - headers: { [name: string]: string }; - body: any; -} +import * as debug from '../../../src/common/debug'; +import * as https from '../../../src/common/providers/https'; +import * as mocks from '../../fixtures/credential/key.json'; /** * A CallTest is a specification for a test of a callable function that @@ -58,64 +46,6 @@ interface CallTest { expectedHttpResponse: RunHandlerResult; } -/** - * Runs an express handler with a given request asynchronously and returns the - * data populated into the response. - */ -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 } = {}; - private callback: Function; - - 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, - }); - if (this.callback) { - this.callback(); - } - } - - public end() { - this.send(undefined); - } - - public on(event: string, callback: Function) { - if (event !== 'finish') { - throw new Error('MockResponse only implements the finish event'); - } - this.callback = callback; - } - } - - const response = new MockResponse(); - handler(request, response as any, () => undefined); - }); -} - // Runs a CallTest test. async function runCallableTest(test: CallTest): Promise { const opts = { @@ -145,81 +75,6 @@ async function runCallableTest(test: CallTest): Promise { expect(responseV2.status).to.equal(test.expectedHttpResponse.status); } -/** Represents a test case for a Task Queue Function */ -interface TaskTest { - // An http request, mocking a subset of https.Request. - httpRequest: any; - - // The expected format of the request passed to the handler. - expectedData: any; - - taskFunction?: ( - data: any, - context: https.TaskContext - ) => void | Promise; - - taskFunction2?: (request: https.TaskRequest) => void | Promise; - - // The expected shape of the http response returned to the callable SDK. - expectedStatus: number; -} - -// Runs a TaskTest test. -async function runTaskTest(test: TaskTest): Promise { - const taskQueueFunctionV1 = https.onDispatchHandler((data, context) => { - expect(data).to.deep.equal(test.expectedData); - if (test.taskFunction) { - test.taskFunction(data, context); - } - }); - - const responseV1 = await runHandler(taskQueueFunctionV1, test.httpRequest); - expect(responseV1.status).to.equal(test.expectedStatus); - - const taskQueueFunctionV2 = https.onDispatchHandler((request) => { - expect(request.data).to.deep.equal(test.expectedData); - if (test.taskFunction2) { - test.taskFunction2(request); - } - }); - - const responseV2 = await runHandler(taskQueueFunctionV2, test.httpRequest); - expect(responseV2.status).to.equal(test.expectedStatus); -} - -function checkAuthContext( - context: CallableContext | CallableRequest | TaskContext | TaskRequest, - projectId: string, - userId: string -) { - expect(context.auth).to.not.be.undefined; - expect(context.auth).to.not.be.null; - expect(context.auth.uid).to.equal(userId); - expect(context.auth.token.uid).to.equal(userId); - expect(context.auth.token.sub).to.equal(userId); - expect(context.auth.token.aud).to.equal(projectId); - - // TaskContext & TaskRequest don't have instanceIdToken - if ({}.hasOwnProperty.call(context, 'instanceIdToken')) { - expect((context as CallableContext).instanceIdToken).to.be.undefined; - } -} - -function checkAppCheckContext( - context: CallableContext | CallableRequest, - projectId: string, - appId: string -) { - expect(context.app).to.not.be.undefined; - expect(context.app).to.not.be.null; - expect(context.app.appId).to.equal(appId); - expect(context.app.token.app_id).to.be.equal(appId); - expect(context.app.token.sub).to.be.equal(appId); - expect(context.app.token.aud).to.be.deep.equal([`projects/${projectId}`]); - expect(context.auth).to.be.undefined; - expect(context.instanceIdToken).to.be.undefined; -} - describe('onCallHandler', () => { let app: firebase.app.App; @@ -667,187 +522,6 @@ describe('onCallHandler', () => { }); }); -describe('onEnqueueHandler', () => { - let app: firebase.app.App; - - before(() => { - const credential = { - getAccessToken: () => { - return Promise.resolve({ - expires_in: 1000, - access_token: 'fake', - }); - }, - getCertificate: () => { - return { - projectId: 'aProjectId', - }; - }, - }; - app = firebase.initializeApp({ - projectId: 'aProjectId', - credential, - }); - Object.defineProperty(appsNamespace(), 'admin', { get: () => app }); - }); - - after(() => { - app.delete(); - delete appsNamespace.singleton; - }); - - it('should handle success', () => { - return runTaskTest({ - httpRequest: mockRequest({ foo: 'bar' }), - expectedData: { foo: 'bar' }, - expectedStatus: 204, - }); - }); - - it('should reject bad method', () => { - const req = mockRequest(null); - req.method = 'GET'; - return runTaskTest({ - httpRequest: req, - expectedData: null, - expectedStatus: 400, - }); - }); - - it('should ignore charset', () => { - return runTaskTest({ - httpRequest: mockRequest(null, 'application/json; charset=utf-8'), - expectedData: null, - expectedStatus: 204, - }); - }); - - it('should reject bad content type', () => { - return runTaskTest({ - httpRequest: mockRequest(null, 'text/plain'), - expectedData: null, - expectedStatus: 400, - }); - }); - - it('should reject extra body fields', () => { - const req = mockRequest(null); - req.body.extra = 'bad'; - return runTaskTest({ - httpRequest: req, - expectedData: null, - expectedStatus: 400, - }); - }); - - it('should handle unhandled error', () => { - return runTaskTest({ - httpRequest: mockRequest(null), - expectedData: null, - taskFunction: (data, context) => { - throw new Error(`ceci n'est pas une error`); - }, - taskFunction2: (request) => { - throw new Error(`cece n'est pas une error`); - }, - expectedStatus: 500, - }); - }); - - it('should handle unknown error status', () => { - return runTaskTest({ - httpRequest: mockRequest(null), - expectedData: null, - taskFunction: (data, context) => { - throw new https.HttpsError('THIS_IS_NOT_VALID' as any, 'nope'); - }, - taskFunction2: (request) => { - throw new https.HttpsError('THIS_IS_NOT_VALID' as any, 'nope'); - }, - expectedStatus: 500, - }); - }); - - it('should handle well-formed error', () => { - return runTaskTest({ - httpRequest: mockRequest(null), - expectedData: null, - taskFunction: (data, context) => { - throw new https.HttpsError('not-found', 'i am error'); - }, - taskFunction2: (request) => { - throw new https.HttpsError('not-found', 'i am error'); - }, - expectedStatus: 404, - }); - }); - - it('should handle auth', async () => { - const mock = mockFetchPublicKeys(); - const projectId = appsNamespace().admin.options.projectId; - const idToken = generateIdToken(projectId); - await runTaskTest({ - httpRequest: mockRequest(null, 'application/json', { - authorization: 'Bearer ' + idToken, - }), - expectedData: null, - taskFunction: (data, context) => { - checkAuthContext(context, projectId, mocks.user_id); - return null; - }, - taskFunction2: (request) => { - checkAuthContext(request, projectId, mocks.user_id); - return null; - }, - expectedStatus: 204, - }); - mock.done(); - }); - - it('should reject bad auth', async () => { - const projectId = appsNamespace().admin.options.projectId; - const idToken = generateUnsignedIdToken(projectId); - await runTaskTest({ - httpRequest: mockRequest(null, 'application/json', { - authorization: 'Bearer ' + idToken, - }), - expectedData: null, - expectedStatus: 401, - }); - }); - - describe('skip token verification debug mode support', () => { - before(() => { - sinon - .stub(debug, 'isDebugFeatureEnabled') - .withArgs('skipTokenVerification') - .returns(true); - }); - - after(() => { - sinon.verifyAndRestore(); - }); - - it('should skip auth token verification', async () => { - const projectId = appsNamespace().admin.options.projectId; - const idToken = generateUnsignedIdToken(projectId); - await runTaskTest({ - httpRequest: mockRequest(null, 'application/json', { - authorization: 'Bearer ' + idToken, - }), - expectedData: null, - taskFunction: (data, context) => { - checkAuthContext(context, projectId, mocks.user_id); - }, - taskFunction2: (request) => { - checkAuthContext(request, projectId, mocks.user_id); - }, - expectedStatus: 204, - }); - }); - }); -}); - describe('encoding/decoding', () => { it('encodes null', () => { expect(https.encode(null)).to.be.null; @@ -977,19 +651,21 @@ describe('decode tokens', () => { const appId = '123:web:abc'; it('decodes valid Auth ID Token', () => { - const idToken = unsafeDecodeIdToken(generateIdToken(projectId)); + const idToken = https.unsafeDecodeIdToken(generateIdToken(projectId)); expect(idToken.uid).to.equal(mocks.user_id); expect(idToken.sub).to.equal(mocks.user_id); }); it('decodes invalid Auth ID Token', () => { - const idToken = unsafeDecodeIdToken(generateUnsignedIdToken(projectId)); + const idToken = https.unsafeDecodeIdToken( + generateUnsignedIdToken(projectId) + ); expect(idToken.uid).to.equal(mocks.user_id); expect(idToken.sub).to.equal(mocks.user_id); }); it('decodes valid App Check Token', () => { - const idToken = unsafeDecodeAppCheckToken( + const idToken = https.unsafeDecodeAppCheckToken( generateAppCheckToken(projectId, appId) ); expect(idToken.app_id).to.equal(appId); @@ -997,7 +673,7 @@ describe('decode tokens', () => { }); it('decodes invalid App Check Token', () => { - const idToken = unsafeDecodeAppCheckToken( + const idToken = https.unsafeDecodeAppCheckToken( generateUnsignedAppCheckToken(projectId, appId) ); expect(idToken.app_id).to.equal(appId); diff --git a/spec/common/providers/tasks.spec.ts b/spec/common/providers/tasks.spec.ts new file mode 100644 index 000000000..a29731c5a --- /dev/null +++ b/spec/common/providers/tasks.spec.ts @@ -0,0 +1,262 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as firebase from 'firebase-admin'; + +import { checkAuthContext, runHandler } from '../../helper'; +import { + generateIdToken, + generateUnsignedIdToken, + mockFetchPublicKeys, + mockRequest, +} from '../../fixtures/mockrequest'; +import { + onDispatchHandler, + TaskContext, + Request, +} from '../../../src/common/providers/tasks'; +import { apps as appsNamespace } from '../../../src/apps'; +import * as mocks from '../../fixtures/credential/key.json'; +import * as https from '../../../src/common/providers/https'; +import * as debug from '../../../src/common/debug'; + +/** Represents a test case for a Task Queue Function */ +interface TaskTest { + // An http request, mocking a subset of https.Request. + httpRequest: any; + + // The expected format of the request passed to the handler. + expectedData: any; + + taskFunction?: (data: any, context: TaskContext) => void | Promise; + + taskFunction2?: (request: Request) => void | Promise; + + // The expected shape of the http response returned to the callable SDK. + expectedStatus: number; +} + +// Runs a TaskTest test. +export async function runTaskTest(test: TaskTest): Promise { + const taskQueueFunctionV1 = onDispatchHandler((data, context) => { + expect(data).to.deep.equal(test.expectedData); + if (test.taskFunction) { + test.taskFunction(data, context); + } + }); + + const responseV1 = await runHandler(taskQueueFunctionV1, test.httpRequest); + expect(responseV1.status).to.equal(test.expectedStatus); + + const taskQueueFunctionV2 = onDispatchHandler((request) => { + expect(request.data).to.deep.equal(test.expectedData); + if (test.taskFunction2) { + test.taskFunction2(request); + } + }); + + const responseV2 = await runHandler(taskQueueFunctionV2, test.httpRequest); + expect(responseV2.status).to.equal(test.expectedStatus); +} + +describe('onEnqueueHandler', () => { + let app: firebase.app.App; + + before(() => { + const credential = { + getAccessToken: () => { + return Promise.resolve({ + expires_in: 1000, + access_token: 'fake', + }); + }, + getCertificate: () => { + return { + projectId: 'aProjectId', + }; + }, + }; + app = firebase.initializeApp({ + projectId: 'aProjectId', + credential, + }); + Object.defineProperty(appsNamespace(), 'admin', { get: () => app }); + }); + + after(() => { + app.delete(); + delete appsNamespace.singleton; + }); + + it('should handle success', () => { + return runTaskTest({ + httpRequest: mockRequest({ foo: 'bar' }), + expectedData: { foo: 'bar' }, + expectedStatus: 204, + }); + }); + + it('should reject bad method', () => { + const req = mockRequest(null); + req.method = 'GET'; + return runTaskTest({ + httpRequest: req, + expectedData: null, + expectedStatus: 400, + }); + }); + + it('should ignore charset', () => { + return runTaskTest({ + httpRequest: mockRequest(null, 'application/json; charset=utf-8'), + expectedData: null, + expectedStatus: 204, + }); + }); + + it('should reject bad content type', () => { + return runTaskTest({ + httpRequest: mockRequest(null, 'text/plain'), + expectedData: null, + expectedStatus: 400, + }); + }); + + it('should reject extra body fields', () => { + const req = mockRequest(null); + req.body.extra = 'bad'; + return runTaskTest({ + httpRequest: req, + expectedData: null, + expectedStatus: 400, + }); + }); + + it('should handle unhandled error', () => { + return runTaskTest({ + httpRequest: mockRequest(null), + expectedData: null, + taskFunction: (data, context) => { + throw new Error(`ceci n'est pas une error`); + }, + taskFunction2: (request) => { + throw new Error(`cece n'est pas une error`); + }, + expectedStatus: 500, + }); + }); + + it('should handle unknown error status', () => { + return runTaskTest({ + httpRequest: mockRequest(null), + expectedData: null, + taskFunction: (data, context) => { + throw new https.HttpsError('THIS_IS_NOT_VALID' as any, 'nope'); + }, + taskFunction2: (request) => { + throw new https.HttpsError('THIS_IS_NOT_VALID' as any, 'nope'); + }, + expectedStatus: 500, + }); + }); + + it('should handle well-formed error', () => { + return runTaskTest({ + httpRequest: mockRequest(null), + expectedData: null, + taskFunction: (data, context) => { + throw new https.HttpsError('not-found', 'i am error'); + }, + taskFunction2: (request) => { + throw new https.HttpsError('not-found', 'i am error'); + }, + expectedStatus: 404, + }); + }); + + it('should handle auth', async () => { + const mock = mockFetchPublicKeys(); + const projectId = appsNamespace().admin.options.projectId; + const idToken = generateIdToken(projectId); + await runTaskTest({ + httpRequest: mockRequest(null, 'application/json', { + authorization: 'Bearer ' + idToken, + }), + expectedData: null, + taskFunction: (data, context) => { + checkAuthContext(context, projectId, mocks.user_id); + return null; + }, + taskFunction2: (request) => { + checkAuthContext(request, projectId, mocks.user_id); + return null; + }, + expectedStatus: 204, + }); + mock.done(); + }); + + it('should reject bad auth', async () => { + const projectId = appsNamespace().admin.options.projectId; + const idToken = generateUnsignedIdToken(projectId); + await runTaskTest({ + httpRequest: mockRequest(null, 'application/json', { + authorization: 'Bearer ' + idToken, + }), + expectedData: null, + expectedStatus: 401, + }); + }); + + describe('skip token verification debug mode support', () => { + before(() => { + sinon + .stub(debug, 'isDebugFeatureEnabled') + .withArgs('skipTokenVerification') + .returns(true); + }); + + after(() => { + sinon.verifyAndRestore(); + }); + + it('should skip auth token verification', async () => { + const projectId = appsNamespace().admin.options.projectId; + const idToken = generateUnsignedIdToken(projectId); + await runTaskTest({ + httpRequest: mockRequest(null, 'application/json', { + authorization: 'Bearer ' + idToken, + }), + expectedData: null, + taskFunction: (data, context) => { + checkAuthContext(context, projectId, mocks.user_id); + }, + taskFunction2: (request) => { + checkAuthContext(request, projectId, mocks.user_id); + }, + expectedStatus: 204, + }); + }); + }); +}); diff --git a/spec/helper.ts b/spec/helper.ts new file mode 100644 index 000000000..f39b96e04 --- /dev/null +++ b/spec/helper.ts @@ -0,0 +1,127 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import * as express from 'express'; +import { expect } from 'chai'; + +import * as https from '../src/common/providers/https'; +import * as tasks from '../src/common/providers/tasks'; + +/** + * RunHandlerResult contains the data from an express.Response. + */ +export interface RunHandlerResult { + status: number; + headers: { [name: string]: string }; + body: any; +} + +/** + * Runs an express handler with a given request asynchronously and returns the + * data populated into the response. + */ +export 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 } = {}; + private callback: Function; + + 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, + }); + if (this.callback) { + this.callback(); + } + } + + public end() { + this.send(undefined); + } + + public on(event: string, callback: Function) { + if (event !== 'finish') { + throw new Error('MockResponse only implements the finish event'); + } + this.callback = callback; + } + } + + const response = new MockResponse(); + handler(request, response as any, () => undefined); + }); +} + +export function checkAuthContext( + context: https.CallableContext | https.CallableRequest | tasks.TaskContext, + projectId: string, + userId: string +) { + expect(context.auth).to.not.be.undefined; + expect(context.auth).to.not.be.null; + expect(context.auth.uid).to.equal(userId); + expect(context.auth.token.uid).to.equal(userId); + expect(context.auth.token.sub).to.equal(userId); + expect(context.auth.token.aud).to.equal(projectId); + + // TaskContext & TaskRequest don't have instanceIdToken + if ({}.hasOwnProperty.call(context, 'instanceIdToken')) { + expect((context as https.CallableContext).instanceIdToken).to.be.undefined; + } +} + +export function checkAppCheckContext( + context: https.CallableContext | https.CallableRequest, + projectId: string, + appId: string +) { + expect(context.app).to.not.be.undefined; + expect(context.app).to.not.be.null; + expect(context.app.appId).to.equal(appId); + expect(context.app.token.app_id).to.be.equal(appId); + expect(context.app.token.sub).to.be.equal(appId); + expect(context.app.token.aud).to.be.deep.equal([`projects/${projectId}`]); + expect(context.auth).to.be.undefined; + expect(context.instanceIdToken).to.be.undefined; +} diff --git a/spec/runtime/loader.spec.ts b/spec/runtime/loader.spec.ts index 6db4370fd..c75400064 100644 --- a/spec/runtime/loader.spec.ts +++ b/spec/runtime/loader.spec.ts @@ -4,9 +4,9 @@ import { expect } from 'chai'; import * as loader from '../../src/runtime/loader'; import * as functions from '../../src/index'; import { - ManifestStack, ManifestEndpoint, ManifestRequiredAPI, + ManifestStack, } from '../../src/runtime/manifest'; describe('extractStack', () => { @@ -44,7 +44,7 @@ describe('extractStack', () => { it('extracts stack with required APIs', () => { const module = { - taskq: functions.https.taskQueue().onDispatch(() => {}), + taskq: functions.tasks.taskQueue().onDispatch(() => {}), }; const endpoints: Record = {}; diff --git a/spec/v1/providers/https.spec.ts b/spec/v1/providers/https.spec.ts index 22e6d52fd..7046ad25e 100644 --- a/spec/v1/providers/https.spec.ts +++ b/spec/v1/providers/https.spec.ts @@ -21,7 +21,6 @@ // SOFTWARE. import { expect } from 'chai'; -import * as express from 'express'; import * as functions from '../../../src/index'; import * as https from '../../../src/providers/https'; @@ -29,68 +28,7 @@ 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 } = {}; - private callback: Function; - - 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, - }); - if (this.callback) { - this.callback(); - } - } - - public end() { - this.send(undefined); - } - - public on(event: string, callback: Function) { - if (event !== 'finish') { - throw new Error('MockResponse only implements the finish event'); - } - this.callback = callback; - } - } - const response = new MockResponse(); - handler(request, response as any, () => undefined); - }); -} +import { runHandler } from '../../helper'; describe('CloudHttpsBuilder', () => { describe('#onRequest', () => { @@ -146,14 +84,6 @@ describe('handler namespace', () => { expect(result.__endpoint).to.be.undefined; }); }); - - describe('#onEnqueue', () => { - it('should return an empty trigger', () => { - const result = functions.handler.https.taskQueue.onEnqueue(() => null); - expect(result.__trigger).to.deep.equal({}); - expect(result.__endpoint).to.be.undefined; - }); - }); }); describe('#onCall', () => { @@ -231,137 +161,6 @@ describe('#onCall', () => { }); }); -describe('#onEnqueue', () => { - it('should return a trigger/endpoint with appropriate values', () => { - const result = https - .taskQueue({ - rateLimits: { - maxBurstSize: 20, - maxConcurrentDispatches: 30, - maxDispatchesPerSecond: 40, - }, - retryConfig: { - maxAttempts: 5, - maxBackoffSeconds: 20, - maxDoublings: 3, - minBackoffSeconds: 5, - }, - invoker: 'private', - }) - .onDispatch(() => {}); - - expect(result.__trigger).to.deep.equal({ - taskQueueTrigger: { - rateLimits: { - maxBurstSize: 20, - maxConcurrentDispatches: 30, - maxDispatchesPerSecond: 40, - }, - retryConfig: { - maxAttempts: 5, - maxBackoffSeconds: 20, - maxDoublings: 3, - minBackoffSeconds: 5, - }, - invoker: ['private'], - }, - }); - - expect(result.__endpoint).to.deep.equal({ - platform: 'gcfv1', - taskQueueTrigger: { - rateLimits: { - maxBurstSize: 20, - maxConcurrentDispatches: 30, - maxDispatchesPerSecond: 40, - }, - retryConfig: { - maxAttempts: 5, - maxBackoffSeconds: 20, - maxDoublings: 3, - minBackoffSeconds: 5, - }, - invoker: ['private'], - }, - }); - }); - - it('should allow both region and runtime options to be set', () => { - const fn = functions - .region('us-east1') - .runWith({ - timeoutSeconds: 90, - memory: '256MB', - }) - .https.taskQueue({ retryConfig: { maxAttempts: 5 } }) - .onDispatch(() => null); - - expect(fn.__trigger).to.deep.equal({ - regions: ['us-east1'], - availableMemoryMb: 256, - timeout: '90s', - taskQueueTrigger: { - retryConfig: { - maxAttempts: 5, - }, - }, - }); - - expect(fn.__endpoint).to.deep.equal({ - platform: 'gcfv1', - region: ['us-east1'], - availableMemoryMb: 256, - timeoutSeconds: 90, - taskQueueTrigger: { - retryConfig: { - maxAttempts: 5, - }, - }, - }); - }); - - it('has a .run method', async () => { - const data = 'data'; - const context = { - auth: { - uid: 'abc', - token: 'token' as any, - }, - }; - let done = false; - const cf = https.taskQueue().onDispatch((d, c) => { - expect(d).to.equal(data); - expect(c).to.deep.equal(context); - done = true; - }); - - await cf.run(data, context); - expect(done).to.be.true; - }); - - // Regression test for firebase-functions#947 - it('should lock to the v1 API even with function.length == 1', async () => { - let gotData: Record; - const func = https.taskQueue().onDispatch((data) => { - gotData = data; - }); - - const req = new MockRequest( - { - data: { foo: 'bar' }, - }, - { - 'content-type': 'application/json', - } - ); - req.method = 'POST'; - - const response = await runHandler(func, req as any); - expect(response.status).to.equal(204); - expect(gotData).to.deep.equal({ foo: 'bar' }); - }); -}); - describe('callable CORS', () => { it('handles OPTIONS preflight', async () => { const func = https.onCall((data, context) => { diff --git a/spec/v1/providers/tasks.spec.ts b/spec/v1/providers/tasks.spec.ts new file mode 100644 index 000000000..640ceed68 --- /dev/null +++ b/spec/v1/providers/tasks.spec.ts @@ -0,0 +1,165 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { expect } from 'chai'; + +import * as functions from '../../../src'; +import { taskQueue } from '../../../src/providers/tasks'; +import { MockRequest } from '../../fixtures/mockrequest'; +import { runHandler } from '../../helper'; + +describe('#onDispatch', () => { + it('should return a trigger/endpoint with appropriate values', () => { + const result = taskQueue({ + rateLimits: { + maxConcurrentDispatches: 30, + maxDispatchesPerSecond: 40, + }, + retryConfig: { + maxAttempts: 5, + maxRetrySeconds: 10, + maxBackoffSeconds: 20, + maxDoublings: 3, + minBackoffSeconds: 5, + }, + invoker: 'private', + }).onDispatch(() => {}); + + expect(result.__trigger).to.deep.equal({ + taskQueueTrigger: { + rateLimits: { + maxConcurrentDispatches: 30, + maxDispatchesPerSecond: 40, + }, + retryConfig: { + maxAttempts: 5, + maxRetrySeconds: 10, + maxBackoffSeconds: 20, + maxDoublings: 3, + minBackoffSeconds: 5, + }, + invoker: ['private'], + }, + }); + + expect(result.__endpoint).to.deep.equal({ + platform: 'gcfv1', + taskQueueTrigger: { + rateLimits: { + maxConcurrentDispatches: 30, + maxDispatchesPerSecond: 40, + }, + retryConfig: { + maxAttempts: 5, + maxRetrySeconds: 10, + maxBackoffSeconds: 20, + maxDoublings: 3, + minBackoffSeconds: 5, + }, + invoker: ['private'], + }, + }); + }); + + it('should allow both region and runtime options to be set', () => { + const fn = functions + .region('us-east1') + .runWith({ + timeoutSeconds: 90, + memory: '256MB', + }) + .tasks.taskQueue({ retryConfig: { maxAttempts: 5 } }) + .onDispatch(() => null); + + expect(fn.__trigger).to.deep.equal({ + regions: ['us-east1'], + availableMemoryMb: 256, + timeout: '90s', + taskQueueTrigger: { + retryConfig: { + maxAttempts: 5, + }, + }, + }); + + expect(fn.__endpoint).to.deep.equal({ + platform: 'gcfv1', + region: ['us-east1'], + availableMemoryMb: 256, + timeoutSeconds: 90, + taskQueueTrigger: { + retryConfig: { + maxAttempts: 5, + }, + }, + }); + }); + + it('has a .run method', async () => { + const data = 'data'; + const context = { + auth: { + uid: 'abc', + token: 'token' as any, + }, + }; + let done = false; + const cf = taskQueue().onDispatch((d, c) => { + expect(d).to.equal(data); + expect(c).to.deep.equal(context); + done = true; + }); + + await cf.run(data, context); + expect(done).to.be.true; + }); + + // Regression test for firebase-functions#947 + it('should lock to the v1 API even with function.length == 1', async () => { + let gotData: Record; + const func = taskQueue().onDispatch((data) => { + gotData = data; + }); + + const req = new MockRequest( + { + data: { foo: 'bar' }, + }, + { + 'content-type': 'application/json', + } + ); + req.method = 'POST'; + + const response = await runHandler(func, req as any); + expect(response.status).to.equal(204); + expect(gotData).to.deep.equal({ foo: 'bar' }); + }); +}); + +describe('handler namespace', () => { + it('should return an empty trigger', () => { + const result = functions.handler.tasks.taskQueue.onDispatch(() => null); + expect(result.__trigger).to.deep.equal({}); + expect(result.__endpoint).to.be.undefined; + }); +}); diff --git a/spec/v2/providers/alerts/alerts.spec.ts b/spec/v2/providers/alerts/alerts.spec.ts index 6ad78a811..8c4741f43 100644 --- a/spec/v2/providers/alerts/alerts.spec.ts +++ b/spec/v2/providers/alerts/alerts.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import * as options from '../../../../src/v2/options'; import * as alerts from '../../../../src/v2/providers/alerts'; -import { FULL_ENDPOINT, FULL_OPTIONS } from '../helpers'; +import { FULL_ENDPOINT, FULL_OPTIONS } from '../fixtures'; const ALERT_TYPE = 'new-alert-type'; const APPID = '123456789'; diff --git a/spec/v2/providers/alerts/appDistribution.spec.ts b/spec/v2/providers/alerts/appDistribution.spec.ts index 3242c7e07..5fb112daa 100644 --- a/spec/v2/providers/alerts/appDistribution.spec.ts +++ b/spec/v2/providers/alerts/appDistribution.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import * as alerts from '../../../../src/v2/providers/alerts'; import * as appDistribution from '../../../../src/v2/providers/alerts/appDistribution'; -import { FULL_ENDPOINT, FULL_OPTIONS } from '../helpers'; +import { FULL_ENDPOINT, FULL_OPTIONS } from '../fixtures'; const APPID = '123456789'; const myHandler = () => 42; diff --git a/spec/v2/providers/alerts/billing.spec.ts b/spec/v2/providers/alerts/billing.spec.ts index f8e0eeebb..4bf5f3080 100644 --- a/spec/v2/providers/alerts/billing.spec.ts +++ b/spec/v2/providers/alerts/billing.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import * as alerts from '../../../../src/v2/providers/alerts'; import * as billing from '../../../../src/v2/providers/alerts/billing'; -import { FULL_ENDPOINT, FULL_OPTIONS } from '../helpers'; +import { FULL_ENDPOINT, FULL_OPTIONS } from '../fixtures'; const ALERT_TYPE = 'new-alert-type'; const myHandler = () => 42; diff --git a/spec/v2/providers/alerts/crashlytics.spec.ts b/spec/v2/providers/alerts/crashlytics.spec.ts index 1ea1c4f0e..08956d8c7 100644 --- a/spec/v2/providers/alerts/crashlytics.spec.ts +++ b/spec/v2/providers/alerts/crashlytics.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import * as alerts from '../../../../src/v2/providers/alerts'; import * as crashlytics from '../../../../src/v2/providers/alerts/crashlytics'; -import { FULL_ENDPOINT, FULL_OPTIONS } from '../helpers'; +import { FULL_ENDPOINT, FULL_OPTIONS } from '../fixtures'; const ALERT_TYPE = 'new-alert-type'; const APPID = '123456789'; diff --git a/spec/v2/providers/helpers.ts b/spec/v2/providers/fixtures.ts similarity index 100% rename from spec/v2/providers/helpers.ts rename to spec/v2/providers/fixtures.ts diff --git a/spec/v2/providers/https.spec.ts b/spec/v2/providers/https.spec.ts index 5f0f7eb74..587be89c6 100644 --- a/spec/v2/providers/https.spec.ts +++ b/spec/v2/providers/https.spec.ts @@ -1,5 +1,26 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + import { expect } from 'chai'; -import * as express from 'express'; import * as options from '../../../src/v2/options'; import * as https from '../../../src/v2/providers/https'; @@ -7,70 +28,8 @@ import { expectedResponseHeaders, MockRequest, } from '../../fixtures/mockrequest'; -import { FULL_ENDPOINT, FULL_OPTIONS, FULL_TRIGGER } from './helpers'; - -/** - * 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 } = {}; - private callback: Function; - - 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, - }); - if (this.callback) { - this.callback(); - } - } - - public end() { - this.send(undefined); - } - - public on(event: string, callback: Function) { - if (event !== 'finish') { - throw new Error('MockResponse only implements the finish event'); - } - this.callback = callback; - } - } - - const response = new MockResponse(); - handler(request, response as any, () => undefined); - }); -} +import { FULL_ENDPOINT, FULL_OPTIONS, FULL_TRIGGER } from './fixtures'; +import { runHandler } from '../../helper'; describe('onRequest', () => { beforeEach(() => { @@ -415,139 +374,3 @@ describe('onCall', () => { https.onCall((request: https.CallableRequest) => `Hello, ${request.data}`); }); }); - -describe('onTaskEnqueue', () => { - 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.onTaskDispatched(() => {}); - expect(result.__trigger).to.deep.equal({ - apiVersion: 2, - platform: 'gcfv2', - taskQueueTrigger: {}, - labels: {}, - }); - }); - - it('should create a complex trigger with appropriate values', () => { - const result = https.onTaskDispatched( - { - ...FULL_OPTIONS, - retryConfig: { - maxAttempts: 4, - maxDoublings: 3, - minBackoffSeconds: 1, - maxBackoffSeconds: 2, - }, - rateLimits: { - maxBurstSize: 10, - maxConcurrentDispatches: 5, - maxDispatchesPerSecond: 10, - }, - invoker: 'private', - }, - () => {} - ); - expect(result.__trigger).to.deep.equal({ - ...FULL_TRIGGER, - taskQueueTrigger: { - retryConfig: { - maxAttempts: 4, - maxDoublings: 3, - minBackoffSeconds: 1, - maxBackoffSeconds: 2, - }, - rateLimits: { - maxBurstSize: 10, - maxConcurrentDispatches: 5, - maxDispatchesPerSecond: 10, - }, - invoker: ['private'], - }, - }); - }); - - it('should merge options and globalOptions', () => { - options.setGlobalOptions({ - concurrency: 20, - region: 'europe-west1', - minInstances: 1, - }); - - const result = https.onTaskDispatched( - { - region: 'us-west1', - minInstances: 3, - }, - (request) => {} - ); - - expect(result.__trigger).to.deep.equal({ - apiVersion: 2, - platform: 'gcfv2', - taskQueueTrigger: {}, - concurrency: 20, - minInstances: 3, - regions: ['us-west1'], - labels: {}, - }); - }); - - it('has a .run method', async () => { - const request: any = { - data: 'data', - auth: { - uid: 'abc', - token: 'token', - }, - }; - const cf = https.onTaskDispatched((r) => { - expect(r.data).to.deep.equal(request.data); - expect(r.auth).to.deep.equal(request.auth); - }); - - await cf.run(request); - }); - - it('should be an express handler', async () => { - const func = https.onTaskDispatched((request) => {}); - - 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.status).to.equal(204); - }); - - // These tests pass if the code transpiles - it('allows desirable syntax', () => { - https.onTaskDispatched((request: https.TaskRequest) => { - // There should be no lint warnings that data is not a string. - console.log(`hello, ${request.data}`); - }); - https.onTaskDispatched((request: https.TaskRequest) => { - console.log(`hello, ${request.data}`); - }); - https.onTaskDispatched((request: https.TaskRequest) => { - console.log(`hello, ${request.data}`); - }); - https.onTaskDispatched((request: https.TaskRequest) => { - console.log(`Hello, ${request.data}`); - }); - }); -}); diff --git a/spec/v2/providers/pubsub.spec.ts b/spec/v2/providers/pubsub.spec.ts index f48e10f72..73e5b55ab 100644 --- a/spec/v2/providers/pubsub.spec.ts +++ b/spec/v2/providers/pubsub.spec.ts @@ -3,7 +3,7 @@ import { expect } from 'chai'; import { CloudEvent } from '../../../src/v2/core'; import * as options from '../../../src/v2/options'; import * as pubsub from '../../../src/v2/providers/pubsub'; -import { FULL_ENDPOINT, FULL_OPTIONS, FULL_TRIGGER } from './helpers'; +import { FULL_ENDPOINT, FULL_OPTIONS, FULL_TRIGGER } from './fixtures'; const EVENT_TRIGGER = { eventType: 'google.cloud.pubsub.topic.v1.messagePublished', diff --git a/spec/v2/providers/storage.spec.ts b/spec/v2/providers/storage.spec.ts index 0471b6931..1c6d7c265 100644 --- a/spec/v2/providers/storage.spec.ts +++ b/spec/v2/providers/storage.spec.ts @@ -3,7 +3,7 @@ import * as sinon from 'sinon'; import * as config from '../../../src/config'; import * as options from '../../../src/v2/options'; import * as storage from '../../../src/v2/providers/storage'; -import { FULL_OPTIONS } from './helpers'; +import { FULL_OPTIONS } from './fixtures'; const EVENT_TRIGGER = { eventType: 'event-type', diff --git a/spec/v2/providers/tasks.spec.ts b/spec/v2/providers/tasks.spec.ts new file mode 100644 index 000000000..4ef4704db --- /dev/null +++ b/spec/v2/providers/tasks.spec.ts @@ -0,0 +1,201 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { expect } from 'chai'; + +import * as options from '../../../src/v2/options'; +import { onTaskDispatched, Request } from '../../../src/v2/providers/tasks'; +import { FULL_ENDPOINT, FULL_OPTIONS, FULL_TRIGGER } from './fixtures'; +import { MockRequest } from '../../fixtures/mockrequest'; +import { runHandler } from '../../helper'; + +describe('onTaskDispatched', () => { + beforeEach(() => { + options.setGlobalOptions({}); + process.env.GCLOUD_PROJECT = 'aProject'; + }); + + afterEach(() => { + delete process.env.GCLOUD_PROJECT; + }); + + it('should return a minimal trigger/endpoint with appropriate values', () => { + const result = onTaskDispatched(() => {}); + + expect(result.__trigger).to.deep.equal({ + apiVersion: 2, + platform: 'gcfv2', + taskQueueTrigger: {}, + labels: {}, + }); + + expect(result.__endpoint).to.deep.equal({ + platform: 'gcfv2', + taskQueueTrigger: {}, + labels: {}, + }); + }); + + it('should create a complex trigger/endpoint with appropriate values', () => { + const result = onTaskDispatched( + { + ...FULL_OPTIONS, + retryConfig: { + maxAttempts: 4, + maxRetrySeconds: 10, + maxDoublings: 3, + minBackoffSeconds: 1, + maxBackoffSeconds: 2, + }, + rateLimits: { + maxConcurrentDispatches: 5, + maxDispatchesPerSecond: 10, + }, + invoker: 'private', + }, + () => {} + ); + + expect(result.__trigger).to.deep.equal({ + ...FULL_TRIGGER, + taskQueueTrigger: { + retryConfig: { + maxAttempts: 4, + maxRetrySeconds: 10, + maxDoublings: 3, + minBackoffSeconds: 1, + maxBackoffSeconds: 2, + }, + rateLimits: { + maxConcurrentDispatches: 5, + maxDispatchesPerSecond: 10, + }, + invoker: ['private'], + }, + }); + + expect(result.__endpoint).to.deep.equal({ + ...FULL_ENDPOINT, + platform: 'gcfv2', + taskQueueTrigger: { + retryConfig: { + maxAttempts: 4, + maxRetrySeconds: 10, + maxDoublings: 3, + minBackoffSeconds: 1, + maxBackoffSeconds: 2, + }, + rateLimits: { + maxConcurrentDispatches: 5, + maxDispatchesPerSecond: 10, + }, + invoker: ['private'], + }, + }); + }); + + it('should merge options and globalOptions', () => { + options.setGlobalOptions({ + concurrency: 20, + region: 'europe-west1', + minInstances: 1, + }); + + const result = onTaskDispatched( + { + region: 'us-west1', + minInstances: 3, + }, + (request) => {} + ); + + expect(result.__trigger).to.deep.equal({ + apiVersion: 2, + platform: 'gcfv2', + taskQueueTrigger: {}, + concurrency: 20, + minInstances: 3, + regions: ['us-west1'], + labels: {}, + }); + + expect(result.__endpoint).to.deep.equal({ + platform: 'gcfv2', + taskQueueTrigger: {}, + concurrency: 20, + minInstances: 3, + region: ['us-west1'], + labels: {}, + }); + }); + + it('has a .run method', async () => { + const request: any = { + data: 'data', + auth: { + uid: 'abc', + token: 'token', + }, + }; + const cf = onTaskDispatched((r) => { + expect(r.data).to.deep.equal(request.data); + expect(r.auth).to.deep.equal(request.auth); + }); + + await cf.run(request); + }); + + it('should be an express handler', async () => { + const func = onTaskDispatched((request) => {}); + + 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.status).to.equal(204); + }); + + // These tests pass if the code transpiles + it('allows desirable syntax', () => { + onTaskDispatched((request: Request) => { + // There should be no lint warnings that data is not a string. + console.log(`hello, ${request.data}`); + }); + onTaskDispatched((request: Request) => { + console.log(`hello, ${request.data}`); + }); + onTaskDispatched((request: Request) => { + console.log(`hello, ${request.data}`); + }); + onTaskDispatched((request: Request) => { + console.log(`Hello, ${request.data}`); + }); + }); +}); diff --git a/src/common/providers/https.ts b/src/common/providers/https.ts index 5421ebb7b..76e02cd37 100644 --- a/src/common/providers/https.ts +++ b/src/common/providers/https.ts @@ -29,7 +29,8 @@ import * as logger from '../../logger'; // TODO(inlined): Decide whether we want to un-version apps or whether we want a // different strategy import { apps } from '../../apps'; -import { isDebugFeatureEnabled } from '../../common/debug'; +import { isDebugFeatureEnabled } from '../debug'; +import { TaskContext } from './tasks'; const JWT_REGEX = /^[a-zA-Z0-9\-_=]+?\.[a-zA-Z0-9\-_=]+?\.([a-zA-Z0-9\-_=]+)?$/; @@ -169,68 +170,6 @@ export interface CallableRequest { rawRequest: Request; } -/** How a task should be retried in the event of a non-2xx return. */ -export interface TaskRetryConfig { - /** - * Maximum number of times a request should be attempted. - * If left unspecified, will default to 3. - */ - maxAttempts?: number; - - /** - * The maximum amount of time to wait between attempts. - * If left unspecified will default to 1hr. - */ - maxBackoffSeconds?: number; - - /** - * The maximum number of times to double the backoff between - * retries. If left unspecified will default to 16. - */ - maxDoublings?: number; - - /** - * The minimum time to wait between attempts. If left unspecified - * will default to 100ms. - */ - minBackoffSeconds?: number; -} - -/** How congestion control should be applied to the function. */ -export interface TaskRateLimits { - // If left unspecified, will default to 100 - maxBurstSize?: number; - - // If left unspecified, wild default to 1000 - maxConcurrentDispatches?: number; - - // If left unspecified, will default to 500 - maxDispatchesPerSecond?: number; -} - -/** Metadata about a call to a Task Queue function. */ -export interface TaskContext { - /** - * The result of decoding and verifying an ODIC token. - */ - auth?: AuthData; -} - -/** - * The request used to call a Task Queue function. - */ -export interface TaskRequest { - /** - * The parameters used by a client when calling this function. - */ - data: T; - - /** - * The result of decoding and verifying an ODIC token. - */ - auth?: AuthData; -} - /** * The set of Firebase Functions status codes. The codes are the same at the * ones exposed by gRPC here: @@ -419,7 +358,7 @@ interface HttpResponseBody { /** @hidden */ // Returns true if req is a properly formatted callable request. -function isValidRequest(req: Request): req is HttpRequest { +export function isValidRequest(req: Request): req is HttpRequest { // The body must not be empty. if (!req.body) { logger.warn('Request is missing body.'); @@ -670,7 +609,7 @@ async function checkTokens( } /** @interanl */ -async function checkAuthToken( +export async function checkAuthToken( req: Request, ctx: CallableContext | TaskContext ): Promise { @@ -739,8 +678,6 @@ type v1CallableHandler = ( context: CallableContext ) => any | Promise; type v2CallableHandler = (request: CallableRequest) => Res; -type v1TaskHandler = (data: any, context: TaskContext) => void | Promise; -type v2TaskHandler = (request: TaskRequest) => void | Promise; /** @internal **/ export interface CallableOptions { @@ -828,50 +765,3 @@ function wrapOnCallHandler( } }; } - -/** @internal */ -export function onDispatchHandler( - handler: v1TaskHandler | v2TaskHandler -): (req: Request, res: express.Response) => Promise { - return async (req: Request, res: express.Response): Promise => { - try { - if (!isValidRequest(req)) { - logger.error('Invalid request, unable to process.'); - throw new HttpsError('invalid-argument', 'Bad Request'); - } - - const context: TaskContext = {}; - const status = await checkAuthToken(req, context); - // Note: this should never happen since task queue functions are guarded by IAM. - if (status === 'INVALID') { - throw new HttpsError('unauthenticated', 'Unauthenticated'); - } - - const data: Req = decode(req.body.data); - if (handler.length === 2) { - await handler(data, context); - } else { - const arg: TaskRequest = { - ...context, - data, - }; - // For some reason the type system isn't picking up that the handler - // is a one argument function. - await (handler as any)(arg); - } - - res.status(204).end(); - } catch (err) { - if (!(err instanceof HttpsError)) { - // This doesn't count as an 'explicit' error. - logger.error('Unhandled error', err); - err = new HttpsError('internal', 'INTERNAL'); - } - - const { status } = err.httpErrorCode; - const body = { error: err.toJSON() }; - - res.status(status).send(body); - } - }; -} diff --git a/src/common/providers/tasks.ts b/src/common/providers/tasks.ts new file mode 100644 index 000000000..57cecd215 --- /dev/null +++ b/src/common/providers/tasks.ts @@ -0,0 +1,147 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import * as express from 'express'; +import * as firebase from 'firebase-admin'; + +import * as logger from '../../logger'; +import * as https from './https'; + +/** How a task should be retried in the event of a non-2xx return. */ +export interface RetryConfig { + /** + * Maximum number of times a request should be attempted. + * If left unspecified, will default to 3. + */ + maxAttempts?: number; + + /** + * Maximum amount of time for retrying failed task. + * If left unspecified will retry indefinitely. + */ + maxRetrySeconds?: number; + + /** + * The maximum amount of time to wait between attempts. + * If left unspecified will default to 1hr. + */ + maxBackoffSeconds?: number; + + /** + * The maximum number of times to double the backoff between + * retries. If left unspecified will default to 16. + */ + maxDoublings?: number; + + /** + * The minimum time to wait between attempts. If left unspecified + * will default to 100ms. + */ + minBackoffSeconds?: number; +} + +/** How congestion control should be applied to the function. */ +export interface RateLimits { + // If left unspecified, wild default to 1000 + maxConcurrentDispatches?: number; + + // If left unspecified, will default to 500 + maxDispatchesPerSecond?: number; +} + +export interface AuthData { + uid: string; + token: firebase.auth.DecodedIdToken; +} + +/** Metadata about a call to a Task Queue function. */ +export interface TaskContext { + /** + * The result of decoding and verifying an ODIC token. + */ + auth?: AuthData; +} + +/** + * The request used to call a Task Queue function. + */ +export interface Request { + /** + * The parameters used by a client when calling this function. + */ + data: T; + + /** + * The result of decoding and verifying an ODIC token. + */ + auth?: AuthData; +} + +type v1TaskHandler = (data: any, context: TaskContext) => void | Promise; +type v2TaskHandler = (request: Request) => void | Promise; + +/** @internal */ +export function onDispatchHandler( + handler: v1TaskHandler | v2TaskHandler +): (req: https.Request, res: express.Response) => Promise { + return async (req: https.Request, res: express.Response): Promise => { + try { + if (!https.isValidRequest(req)) { + logger.error('Invalid request, unable to process.'); + throw new https.HttpsError('invalid-argument', 'Bad Request'); + } + + const context: TaskContext = {}; + const status = await https.checkAuthToken(req, context); + // Note: this should never happen since task queue functions are guarded by IAM. + if (status === 'INVALID') { + throw new https.HttpsError('unauthenticated', 'Unauthenticated'); + } + + const data: Req = https.decode(req.body.data); + if (handler.length === 2) { + await handler(data, context); + } else { + const arg: Request = { + ...context, + data, + }; + // For some reason the type system isn't picking up that the handler + // is a one argument function. + await (handler as any)(arg); + } + + res.status(204).end(); + } catch (err) { + if (!(err instanceof https.HttpsError)) { + // This doesn't count as an 'explicit' error. + logger.error('Unhandled error', err); + err = new https.HttpsError('internal', 'INTERNAL'); + } + + const { status } = err.httpErrorCode; + const body = { error: err.toJSON() }; + + res.status(status).send(body); + } + }; +} diff --git a/src/function-builder.ts b/src/function-builder.ts index cf52bfb56..88f58bb1c 100644 --- a/src/function-builder.ts +++ b/src/function-builder.ts @@ -42,6 +42,7 @@ import * as https from './providers/https'; import * as pubsub from './providers/pubsub'; import * as remoteConfig from './providers/remoteConfig'; import * as storage from './providers/storage'; +import * as tasks from './providers/tasks'; import * as testLab from './providers/testLab'; /** @@ -367,14 +368,18 @@ export class FunctionBuilder { context: https.CallableContext ) => any | Promise ) => https._onCallWithOptions(handler, this.options), + }; + } + get tasks() { + return { /** * Declares a task queue function for clients to call using a Firebase Admin SDK. * @param options Configurations for the task queue function. */ /** @hidden */ - taskQueue: (options?: https.TaskQueueOptions) => { - return new https.TaskQueueBuilder(options, this.options); + taskQueue: (options?: tasks.TaskQueueOptions) => { + return new tasks.TaskQueueBuilder(options, this.options); }, }; } diff --git a/src/handler-builder.ts b/src/handler-builder.ts index c89f3c936..efa0d9f4c 100644 --- a/src/handler-builder.ts +++ b/src/handler-builder.ts @@ -32,6 +32,7 @@ import * as https from './providers/https'; import * as pubsub from './providers/pubsub'; import * as remoteConfig from './providers/remoteConfig'; import * as storage from './providers/storage'; +import * as tasks from './providers/tasks'; import * as testLab from './providers/testLab'; /** @@ -86,16 +87,29 @@ export class HandlerBuilder { func.__requiredAPIs = undefined; return func; }, - /** @hidden */ + }; + } + + /** + * Create a handler for tasks functions. + * + * @example + * ```javascript + * exports.myFunction = functions.handler.tasks.onDispatch((data, context) => { ... }) + * ``` + */ + /** @hidden */ + get tasks() { + return { get taskQueue() { return { - onEnqueue( + onDispatch: ( handler: ( data: any, - context: https.TaskContext + context: tasks.TaskContext ) => void | Promise - ) { - const builder = new https.TaskQueueBuilder(); + ): HttpsFunction => { + const builder = new tasks.TaskQueueBuilder(); const func = builder.onDispatch(handler); func.__trigger = {}; func.__endpoint = undefined; diff --git a/src/index.ts b/src/index.ts index dbb42003c..a77ba3ff9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ import * as https from './providers/https'; import * as pubsub from './providers/pubsub'; import * as remoteConfig from './providers/remoteConfig'; import * as storage from './providers/storage'; +import * as tasks from './providers/tasks'; import * as testLab from './providers/testLab'; import * as apps from './apps'; @@ -49,6 +50,7 @@ export { pubsub, remoteConfig, storage, + tasks, testLab, logger, }; diff --git a/src/providers/https.ts b/src/providers/https.ts index dc62458a7..842ebd84c 100644 --- a/src/providers/https.ts +++ b/src/providers/https.ts @@ -28,37 +28,17 @@ import { optionsToTrigger, Runnable, } from '../cloud-functions'; -import { - convertIfPresent, - convertInvoker, - copyIfPresent, -} from '../common/encoding'; -import { ManifestEndpoint, ManifestRequiredAPI } from '../runtime/manifest'; +import { convertIfPresent, convertInvoker } from '../common/encoding'; import { CallableContext, FunctionsErrorCode, HttpsError, onCallHandler, - onDispatchHandler, Request, - TaskContext, - TaskRateLimits, - TaskRetryConfig, } from '../common/providers/https'; import { DeploymentOptions } from '../function-configuration'; -export { - Request, - CallableContext, - FunctionsErrorCode, - HttpsError, - /** @hidden */ - TaskRetryConfig as TaskRetryPolicy, - /** @hidden */ - TaskRateLimits, - /** @hidden */ - TaskContext, -}; +export { Request, CallableContext, FunctionsErrorCode, HttpsError }; /** * Handle HTTP requests. @@ -81,101 +61,6 @@ export function onCall( return _onCallWithOptions(handler, {}); } -/** - * Configurations for Task Queue Functions. - * @hidden - */ -export interface TaskQueueOptions { - retryConfig?: TaskRetryConfig; - rateLimits?: TaskRateLimits; - - /** - * Who can enqueue tasks for this function. - * If left unspecified, only service accounts which have - * roles/cloudtasks.enqueuer and roles/cloudfunctions.invoker - * will have permissions. - */ - invoker?: 'private' | string | string[]; -} - -/** @hidden */ -export interface TaskQueueFunction { - (req: Request, res: express.Response): Promise; - __trigger: unknown; - __endpoint: ManifestEndpoint; - __requiredAPIs?: ManifestRequiredAPI[]; - run(data: any, context: TaskContext): void | Promise; -} - -/** @hidden */ -export class TaskQueueBuilder { - /** @internal */ - constructor( - private readonly tqOpts?: TaskQueueOptions, - private readonly depOpts?: DeploymentOptions - ) {} - - onDispatch( - handler: (data: any, context: TaskContext) => void | Promise - ): TaskQueueFunction { - // onEnqueueHandler sniffs the function length of the passed-in callback - // and the user could have only tried to listen to data. Wrap their handler - // in another handler to avoid accidentally triggering the v2 API - const fixedLen = (data: any, context: TaskContext) => - handler(data, context); - const func: any = onDispatchHandler(fixedLen); - - func.__trigger = { - ...optionsToTrigger(this.depOpts || {}), - taskQueueTrigger: {}, - }; - copyIfPresent(func.__trigger.taskQueueTrigger, this.tqOpts, 'retryConfig'); - copyIfPresent(func.__trigger.taskQueueTrigger, this.tqOpts, 'rateLimits'); - convertIfPresent( - func.__trigger.taskQueueTrigger, - this.tqOpts, - 'invoker', - 'invoker', - convertInvoker - ); - - func.__endpoint = { - platform: 'gcfv1', - ...optionsToEndpoint(this.depOpts), - taskQueueTrigger: {}, - }; - copyIfPresent(func.__endpoint.taskQueueTrigger, this.tqOpts, 'retryConfig'); - copyIfPresent(func.__endpoint.taskQueueTrigger, this.tqOpts, 'rateLimits'); - convertIfPresent( - func.__endpoint.taskQueueTrigger, - this.tqOpts, - 'invoker', - 'invoker', - convertInvoker - ); - - func.__requiredAPIs = [ - { - api: 'cloudtasks.googleapis.com', - reason: 'Needed for task queue functions', - }, - ]; - - func.run = handler; - - return func; - } -} - -/** - * Declares a function that can handle tasks enqueued using the Firebase Admin SDK. - * @param options Configuration for the Task Queue that feeds into this function. - * @hidden - */ -export function taskQueue(options?: TaskQueueOptions): TaskQueueBuilder { - return new TaskQueueBuilder(options); -} - /** @hidden */ export function _onRequestWithOptions( handler: (req: Request, resp: express.Response) => void | Promise, diff --git a/src/providers/tasks.ts b/src/providers/tasks.ts new file mode 100644 index 000000000..5d9520497 --- /dev/null +++ b/src/providers/tasks.ts @@ -0,0 +1,145 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import * as express from 'express'; + +import { Request } from '../common/providers/https'; +import { ManifestEndpoint, ManifestRequiredAPI } from '../runtime/manifest'; +import { DeploymentOptions } from '../function-configuration'; +import { optionsToEndpoint, optionsToTrigger } from '../cloud-functions'; +import { + convertIfPresent, + convertInvoker, + copyIfPresent, +} from '../common/encoding'; +import { + onDispatchHandler, + TaskContext, + RateLimits, + RetryConfig, +} from '../common/providers/tasks'; + +export { + /** @hidden */ + RetryConfig as RetryPolicy, + /** @hidden */ + RateLimits, + /** @hidden */ + TaskContext, +}; + +/** + * Configurations for Task Queue Functions. + * @hidden + */ +export interface TaskQueueOptions { + retryConfig?: RetryConfig; + rateLimits?: RateLimits; + + /** + * Who can enqueue tasks for this function. + * If left unspecified, only service accounts which have + * roles/cloudtasks.enqueuer and roles/cloudfunctions.invoker + * will have permissions. + */ + invoker?: 'private' | string | string[]; +} + +/** @hidden */ +export interface TaskQueueFunction { + (req: Request, res: express.Response): Promise; + + __trigger: unknown; + __endpoint: ManifestEndpoint; + __requiredAPIs?: ManifestRequiredAPI[]; + + run(data: any, context: TaskContext): void | Promise; +} + +/** @hidden */ +export class TaskQueueBuilder { + /** @internal */ + constructor( + private readonly tqOpts?: TaskQueueOptions, + private readonly depOpts?: DeploymentOptions + ) {} + + onDispatch( + handler: (data: any, context: TaskContext) => void | Promise + ): TaskQueueFunction { + // onEnqueueHandler sniffs the function length of the passed-in callback + // and the user could have only tried to listen to data. Wrap their handler + // in another handler to avoid accidentally triggering the v2 API + const fixedLen = (data: any, context: TaskContext) => + handler(data, context); + const func: any = onDispatchHandler(fixedLen); + + func.__trigger = { + ...optionsToTrigger(this.depOpts || {}), + taskQueueTrigger: {}, + }; + copyIfPresent(func.__trigger.taskQueueTrigger, this.tqOpts, 'retryConfig'); + copyIfPresent(func.__trigger.taskQueueTrigger, this.tqOpts, 'rateLimits'); + convertIfPresent( + func.__trigger.taskQueueTrigger, + this.tqOpts, + 'invoker', + 'invoker', + convertInvoker + ); + + func.__endpoint = { + platform: 'gcfv1', + ...optionsToEndpoint(this.depOpts), + taskQueueTrigger: {}, + }; + copyIfPresent(func.__endpoint.taskQueueTrigger, this.tqOpts, 'retryConfig'); + copyIfPresent(func.__endpoint.taskQueueTrigger, this.tqOpts, 'rateLimits'); + convertIfPresent( + func.__endpoint.taskQueueTrigger, + this.tqOpts, + 'invoker', + 'invoker', + convertInvoker + ); + + func.__requiredAPIs = [ + { + api: 'cloudtasks.googleapis.com', + reason: 'Needed for task queue functions', + }, + ]; + + func.run = handler; + + return func; + } +} + +/** + * Declares a function that can handle tasks enqueued using the Firebase Admin SDK. + * @param options Configuration for the Task Queue that feeds into this function. + * @hidden + */ +export function taskQueue(options?: TaskQueueOptions): TaskQueueBuilder { + return new TaskQueueBuilder(options); +} diff --git a/src/v2/index.ts b/src/v2/index.ts index 3de9b749f..3f6873810 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -26,8 +26,9 @@ import * as alerts from './providers/alerts'; import * as https from './providers/https'; import * as pubsub from './providers/pubsub'; import * as storage from './providers/storage'; +import * as tasks from './providers/tasks'; -export { https, pubsub, storage, logger, params, alerts }; +export { alerts, https, pubsub, storage, logger, params, tasks }; export { setGlobalOptions, GlobalOptions } from './options'; diff --git a/src/v2/providers/https.ts b/src/v2/providers/https.ts index f7b51e508..24367759c 100644 --- a/src/v2/providers/https.ts +++ b/src/v2/providers/https.ts @@ -22,11 +22,7 @@ import * as cors from 'cors'; import * as express from 'express'; -import { - convertIfPresent, - convertInvoker, - copyIfPresent, -} from '../../common/encoding'; +import { convertIfPresent, convertInvoker } from '../../common/encoding'; import * as options from '../options'; import { @@ -34,23 +30,11 @@ import { FunctionsErrorCode, HttpsError, onCallHandler, - onDispatchHandler, Request, - TaskRateLimits, - TaskRequest, - TaskRetryConfig, } from '../../common/providers/https'; import { ManifestEndpoint } from '../../runtime/manifest'; -export { - Request, - CallableRequest, - FunctionsErrorCode, - HttpsError, - TaskRateLimits, - TaskRequest, - TaskRetryConfig as TaskRetryPolicy, -}; +export { Request, CallableRequest, FunctionsErrorCode, HttpsError }; export interface HttpsOptions extends Omit { region?: @@ -60,18 +44,6 @@ export interface HttpsOptions extends Omit { cors?: string | boolean | RegExp | Array; } -export interface TaskQueueOptions extends options.GlobalOptions { - retryConfig?: TaskRetryConfig; - rateLimits?: TaskRateLimits; - /** - * Who can enqueue tasks for this function. - * If left unspecified, only service accounts which have - * roles/cloudtasks.enqueuer and roles/cloudfunctions.invoker - * will have permissions. - */ - invoker?: 'private' | string | string[]; -} - export type HttpsFunction = (( req: Request, res: express.Response @@ -82,9 +54,6 @@ export type HttpsFunction = (( export interface CallableFunction extends HttpsFunction { run(data: CallableRequest): Return; } -export interface TaskQueueFunction extends HttpsFunction { - run(data: TaskRequest): void | Promise; -} export function onRequest( opts: HttpsOptions, @@ -269,72 +238,3 @@ export function onCall>( func.run = handler; return func; } - -/** Handle a request sent to a Cloud Tasks queue. */ -export function onTaskDispatched( - handler: (request: TaskRequest) => void | Promise -): TaskQueueFunction; - -/** Handle a request sent to a Cloud Tasks queue. */ -export function onTaskDispatched( - options: TaskQueueOptions, - handler: (request: TaskRequest) => void | Promise -): TaskQueueFunction; - -export function onTaskDispatched( - optsOrHandler: - | TaskQueueOptions - | ((request: TaskRequest) => void | Promise), - handler?: (request: TaskRequest) => void | Promise -): TaskQueueFunction { - let opts: TaskQueueOptions; - if (arguments.length == 1) { - opts = {}; - handler = optsOrHandler as ( - request: TaskRequest - ) => void | Promise; - } else { - opts = optsOrHandler as TaskQueueOptions; - } - - // onEnqueueHandler sniffs the function length to determine which API to present. - // fix the length to prevent api versions from being mismatched. - const fixedLen = (req: TaskRequest) => handler(req); - const func: any = onDispatchHandler(fixedLen); - - Object.defineProperty(func, '__trigger', { - get: () => { - const baseOpts = options.optionsToTriggerAnnotations( - options.getGlobalOptions() - ); - // global options calls region a scalar and https allows it to be an array, - // but optionsToTriggerAnnotations handles both cases. - const specificOpts = options.optionsToTriggerAnnotations( - opts as options.GlobalOptions - ); - const taskQueueTrigger: Record = {}; - copyIfPresent(taskQueueTrigger, opts, 'retryConfig', 'rateLimits'); - convertIfPresent( - taskQueueTrigger, - opts, - 'invoker', - 'invoker', - convertInvoker - ); - return { - apiVersion: 2, - platform: 'gcfv2', - ...baseOpts, - ...specificOpts, - labels: { - ...baseOpts?.labels, - ...specificOpts?.labels, - }, - taskQueueTrigger, - }; - }, - }); - - func.run = handler; - return func; -} diff --git a/src/v2/providers/tasks.ts b/src/v2/providers/tasks.ts new file mode 100644 index 000000000..ab5892bb5 --- /dev/null +++ b/src/v2/providers/tasks.ts @@ -0,0 +1,150 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import * as options from '../options'; +import { + convertIfPresent, + convertInvoker, + copyIfPresent, +} from '../../common/encoding'; +import { HttpsFunction } from './https'; +import { + AuthData, + RateLimits, + Request, + RetryConfig, + onDispatchHandler, +} from '../../common/providers/tasks'; + +export { AuthData, RateLimits, Request, RetryConfig as RetryPolicy }; + +export interface TaskQueueOptions extends options.GlobalOptions { + retryConfig?: RetryConfig; + rateLimits?: RateLimits; + /** + * Who can enqueue tasks for this function. + * If left unspecified, only service accounts which have + * roles/cloudtasks.enqueuer and roles/cloudfunctions.invoker + * will have permissions. + */ + invoker?: 'private' | string | string[]; +} + +export interface TaskQueueFunction extends HttpsFunction { + run(data: Request): void | Promise; +} + +/** Handle a request sent to a Cloud Tasks queue. */ +export function onTaskDispatched( + handler: (request: Request) => void | Promise +): TaskQueueFunction; +/** Handle a request sent to a Cloud Tasks queue. */ +export function onTaskDispatched( + options: TaskQueueOptions, + handler: (request: Request) => void | Promise +): TaskQueueFunction; +export function onTaskDispatched( + optsOrHandler: + | TaskQueueOptions + | ((request: Request) => void | Promise), + handler?: (request: Request) => void | Promise +): TaskQueueFunction { + let opts: TaskQueueOptions; + if (arguments.length == 1) { + opts = {}; + handler = optsOrHandler as (request: Request) => void | Promise; + } else { + opts = optsOrHandler as TaskQueueOptions; + } + + // onDispatchHandler sniffs the function length to determine which API to present. + // fix the length to prevent api versions from being mismatched. + const fixedLen = (req: Request) => handler(req); + const func: any = onDispatchHandler(fixedLen); + + Object.defineProperty(func, '__trigger', { + get: () => { + const baseOpts = options.optionsToTriggerAnnotations( + options.getGlobalOptions() + ); + // global options calls region a scalar and https allows it to be an array, + // but optionsToTriggerAnnotations handles both cases. + const specificOpts = options.optionsToTriggerAnnotations( + opts as options.GlobalOptions + ); + const taskQueueTrigger: Record = {}; + copyIfPresent(taskQueueTrigger, opts, 'retryConfig', 'rateLimits'); + convertIfPresent( + taskQueueTrigger, + opts, + 'invoker', + 'invoker', + convertInvoker + ); + return { + apiVersion: 2, + platform: 'gcfv2', + ...baseOpts, + ...specificOpts, + labels: { + ...baseOpts?.labels, + ...specificOpts?.labels, + }, + taskQueueTrigger, + }; + }, + }); + + const baseOpts = options.optionsToEndpoint(options.getGlobalOptions()); + // global options calls region a scalar and https allows it to be an array, + // but optionsToManifestEndpoint handles both cases. + const specificOpts = options.optionsToEndpoint(opts as options.GlobalOptions); + func.__endpoint = { + platform: 'gcfv2', + ...baseOpts, + ...specificOpts, + labels: { + ...baseOpts?.labels, + ...specificOpts?.labels, + }, + taskQueueTrigger: {}, + }; + copyIfPresent(func.__endpoint.taskQueueTrigger, opts, 'retryConfig'); + copyIfPresent(func.__endpoint.taskQueueTrigger, opts, 'rateLimits'); + convertIfPresent( + func.__endpoint.taskQueueTrigger, + opts, + 'invoker', + 'invoker', + convertInvoker + ); + + func.__requiredAPIs = [ + { + api: 'cloudtasks.googleapis.com', + reason: 'Needed for task queue functions', + }, + ]; + + func.run = handler; + return func; +} diff --git a/v1/tasks.js b/v1/tasks.js new file mode 100644 index 000000000..ae33ba821 --- /dev/null +++ b/v1/tasks.js @@ -0,0 +1,26 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// This file is not part of the firebase-functions SDK. It is used to silence the +// imports eslint plugin until it can understand import paths defined by node +// package exports. +// For more information, see github.com/import-js/eslint-plugin-import/issues/1810 diff --git a/v2/tasks.js b/v2/tasks.js new file mode 100644 index 000000000..ae33ba821 --- /dev/null +++ b/v2/tasks.js @@ -0,0 +1,26 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// This file is not part of the firebase-functions SDK. It is used to silence the +// imports eslint plugin until it can understand import paths defined by node +// package exports. +// For more information, see github.com/import-js/eslint-plugin-import/issues/1810