diff --git a/package.json b/package.json index 8ffac83b1..168e595ac 100644 --- a/package.json +++ b/package.json @@ -184,7 +184,6 @@ "@types/express": "4.17.3", "cors": "^2.8.5", "express": "^4.17.1", - "lodash": "^4.17.14", "node-fetch": "^2.6.7" }, "devDependencies": { @@ -194,7 +193,6 @@ "@types/chai": "^4.1.7", "@types/chai-as-promised": "^7.1.0", "@types/jsonwebtoken": "^8.3.2", - "@types/lodash": "^4.14.135", "@types/mocha": "^5.2.7", "@types/mock-require": "^2.0.0", "@types/nock": "^10.0.3", @@ -234,4 +232,4 @@ "engines": { "node": ">=14.10.0" } -} +} \ No newline at end of file diff --git a/spec/common/providers/https.spec.ts b/spec/common/providers/https.spec.ts index 8916a371a..e7966b3f7 100644 --- a/spec/common/providers/https.spec.ts +++ b/spec/common/providers/https.spec.ts @@ -1,8 +1,8 @@ import { expect } from 'chai'; -import * as firebase from 'firebase-admin'; +import { App, deleteApp, initializeApp } from 'firebase-admin/app'; import * as sinon from 'sinon'; -import { apps as appsNamespace } from '../../../src/apps'; +import { getApp, setApp } from '../../../src/common/app'; import * as debug from '../../../src/common/debug'; import * as https from '../../../src/common/providers/https'; import * as mocks from '../../fixtures/credential/key.json'; @@ -76,7 +76,7 @@ async function runCallableTest(test: CallTest): Promise { } describe('onCallHandler', () => { - let app: firebase.app.App; + let app: App; before(() => { const credential = { @@ -92,16 +92,16 @@ describe('onCallHandler', () => { }; }, }; - app = firebase.initializeApp({ + app = initializeApp({ projectId: 'aProjectId', credential, }); - Object.defineProperty(appsNamespace(), 'admin', { get: () => app }); + setApp(app); }); after(() => { - app.delete(); - delete appsNamespace.singleton; + deleteApp(app); + setApp(undefined); }); it('should handle success', () => { @@ -288,7 +288,7 @@ describe('onCallHandler', () => { it('should handle auth', async () => { const mock = mockFetchPublicKeys(); - const projectId = appsNamespace().admin.options.projectId; + const projectId = getApp().options.projectId; const idToken = generateIdToken(projectId); await runCallableTest({ httpRequest: mockRequest(null, 'application/json', { @@ -313,7 +313,7 @@ describe('onCallHandler', () => { }); it('should reject bad auth', async () => { - const projectId = appsNamespace().admin.options.projectId; + const projectId = getApp().options.projectId; const idToken = generateUnsignedIdToken(projectId); await runCallableTest({ httpRequest: mockRequest(null, 'application/json', { @@ -341,7 +341,7 @@ describe('onCallHandler', () => { it('should handle AppCheck token', async () => { const mock = mockFetchAppCheckPublicJwks(); - const projectId = appsNamespace().admin.options.projectId; + const projectId = getApp().options.projectId; const appId = '123:web:abc'; const appCheckToken = generateAppCheckToken(projectId, appId); await runCallableTest({ @@ -365,7 +365,7 @@ describe('onCallHandler', () => { }); it('should reject bad AppCheck token', async () => { - const projectId = appsNamespace().admin.options.projectId; + const projectId = getApp().options.projectId; const appId = '123:web:abc'; const appCheckToken = generateUnsignedAppCheckToken(projectId, appId); await runCallableTest({ @@ -474,7 +474,7 @@ describe('onCallHandler', () => { }); it('should skip auth token verification', async () => { - const projectId = appsNamespace().admin.options.projectId; + const projectId = getApp().options.projectId; const idToken = generateUnsignedIdToken(projectId); await runCallableTest({ httpRequest: mockRequest(null, 'application/json', { @@ -498,7 +498,7 @@ describe('onCallHandler', () => { }); it('should skip app check token verification', async () => { - const projectId = appsNamespace().admin.options.projectId; + const projectId = getApp().options.projectId; const appId = '123:web:abc'; const appCheckToken = generateUnsignedAppCheckToken(projectId, appId); await runCallableTest({ diff --git a/spec/common/providers/tasks.spec.ts b/spec/common/providers/tasks.spec.ts index d3711a18f..d25d87068 100644 --- a/spec/common/providers/tasks.spec.ts +++ b/spec/common/providers/tasks.spec.ts @@ -21,9 +21,9 @@ // SOFTWARE. import { expect } from 'chai'; -import * as firebase from 'firebase-admin'; +import { App, deleteApp, initializeApp } from 'firebase-admin/app'; -import { apps as appsNamespace } from '../../../src/apps'; +import { getApp, setApp } from '../../../src/common/app'; import * as https from '../../../src/common/providers/https'; import { onDispatchHandler, @@ -78,7 +78,7 @@ export async function runTaskTest(test: TaskTest): Promise { } describe('onEnqueueHandler', () => { - let app: firebase.app.App; + let app: App; function mockEnqueueRequest( data: unknown, @@ -102,16 +102,16 @@ describe('onEnqueueHandler', () => { }; }, }; - app = firebase.initializeApp({ + app = initializeApp({ projectId: 'aProjectId', credential, }); - Object.defineProperty(appsNamespace(), 'admin', { get: () => app }); + setApp(app); }); after(() => { - app.delete(); - delete appsNamespace.singleton; + deleteApp(app); + setApp(undefined); }); it('should handle success', () => { @@ -201,7 +201,7 @@ describe('onEnqueueHandler', () => { }); it('should handle auth', async () => { - const projectId = appsNamespace().admin.options.projectId; + const projectId = getApp().options.projectId; const idToken = generateIdToken(projectId); await runTaskTest({ httpRequest: mockEnqueueRequest(null, 'application/json', { @@ -221,7 +221,7 @@ describe('onEnqueueHandler', () => { }); it('should accept unsigned auth too', async () => { - const projectId = appsNamespace().admin.options.projectId; + const projectId = getApp().options.projectId; const idToken = generateUnsignedIdToken(projectId); await runTaskTest({ httpRequest: mockEnqueueRequest(null, 'application/json', { diff --git a/spec/fixtures/mockrequest.ts b/spec/fixtures/mockrequest.ts index 5accb339e..60a0822c0 100644 --- a/spec/fixtures/mockrequest.ts +++ b/spec/fixtures/mockrequest.ts @@ -1,6 +1,5 @@ import * as jwt from 'jsonwebtoken'; import * as jwkToPem from 'jwk-to-pem'; -import * as _ from 'lodash'; import * as nock from 'nock'; import * as mockJWK from '../fixtures/credential/jwk.json'; import * as mockKey from '../fixtures/credential/key.json'; @@ -32,7 +31,7 @@ export function mockRequest( } = {} ) { const body: any = {}; - if (!_.isUndefined(data)) { + if (typeof data !== 'undefined') { body.data = data; } diff --git a/spec/v1/apps.spec.ts b/spec/v1/apps.spec.ts deleted file mode 100644 index 89a1b3f5b..000000000 --- a/spec/v1/apps.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2017 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 { apps as appsNamespace } from '../../src/apps'; - -import * as firebase from 'firebase-admin'; -import * as _ from 'lodash'; -import * as sinon from 'sinon'; - -describe('apps', () => { - let apps: appsNamespace.Apps; - - beforeEach(() => { - apps = new appsNamespace.Apps(); - }); - - afterEach(() => { - _.forEach(firebase.apps, (app) => { - app.delete(); - }); - }); - - describe('retain/release', () => { - let clock: sinon.SinonFakeTimers; - - beforeEach(() => { - clock = sinon.useFakeTimers(); - }); - - afterEach(() => { - clock.restore(); - }); - - it('should retain/release ref counters appropriately', () => { - apps.retain(); - expect(_.get(apps, '_refCounter')).to.deep.equal({ - __admin__: 1, - }); - apps.release(); - clock.tick(appsNamespace.garbageCollectionInterval); - return Promise.resolve().then(() => { - expect(_.get(apps, '_refCounter')).to.deep.equal({ - __admin__: 0, - }); - }); - }); - - it('should only decrement counter after garbageCollectionInterval is up', () => { - apps.retain(); - apps.release(); - clock.tick(appsNamespace.garbageCollectionInterval / 2); - expect(_.get(apps, '_refCounter')).to.deep.equal({ - __admin__: 1, - }); - clock.tick(appsNamespace.garbageCollectionInterval / 2); - return Promise.resolve().then(() => { - expect(_.get(apps, '_refCounter')).to.deep.equal({ - __admin__: 0, - }); - }); - }); - - it('should call _destroyApp if app no longer used', () => { - const spy = sinon.spy(apps, '_destroyApp'); - apps.retain(); - apps.release(); - clock.tick(appsNamespace.garbageCollectionInterval); - return Promise.resolve().then(() => { - expect(spy.called).to.be.true; - }); - }); - - it('should not call _destroyApp if app used again while waiting for release', () => { - const spy = sinon.spy(apps, '_destroyApp'); - apps.retain(); - apps.release(); - clock.tick(appsNamespace.garbageCollectionInterval / 2); - apps.retain(); - clock.tick(appsNamespace.garbageCollectionInterval / 2); - return Promise.resolve().then(() => { - expect(spy.called).to.be.false; - }); - }); - - it('should increment ref counter for each subsequent retain', () => { - apps.retain(); - expect(_.get(apps, '_refCounter')).to.deep.equal({ - __admin__: 1, - }); - apps.retain(); - expect(_.get(apps, '_refCounter')).to.deep.equal({ - __admin__: 2, - }); - apps.retain(); - expect(_.get(apps, '_refCounter')).to.deep.equal({ - __admin__: 3, - }); - }); - - it('should work with staggering sets of retain/release', () => { - apps.retain(); - apps.release(); - clock.tick(appsNamespace.garbageCollectionInterval / 2); - apps.retain(); - apps.release(); - clock.tick(appsNamespace.garbageCollectionInterval / 2); - return Promise.resolve() - .then(() => { - // Counters are still 1 due second set of retain/release - expect(_.get(apps, '_refCounter')).to.deep.equal({ - __admin__: 1, - }); - clock.tick(appsNamespace.garbageCollectionInterval / 2); - }) - .then(() => { - // It's now been a full interval since the second set of retain/release - expect(_.get(apps, '_refCounter')).to.deep.equal({ - __admin__: 0, - }); - }); - }); - }); -}); diff --git a/spec/v1/cloud-functions.spec.ts b/spec/v1/cloud-functions.spec.ts index fec1bd580..ab4209025 100644 --- a/spec/v1/cloud-functions.spec.ts +++ b/spec/v1/cloud-functions.spec.ts @@ -21,7 +21,6 @@ // SOFTWARE. import { expect } from 'chai'; -import * as _ from 'lodash'; import { Change, @@ -176,9 +175,10 @@ describe('makeCloudFunction', () => { }); it('should construct the right context for event', () => { - const args: any = _.assign({}, cloudFunctionArgs, { + const args: any = { + ...cloudFunctionArgs, handler: (data: any, context: EventContext) => context, - }); + }; const cf = makeCloudFunction(args); const test: Event = { context: { @@ -206,10 +206,11 @@ describe('makeCloudFunction', () => { }); it('should throw error when context.params accessed in handler environment', () => { - const args: any = _.assign({}, cloudFunctionArgs, { + const args: any = { + ...cloudFunctionArgs, handler: (data: any, context: EventContext) => context, triggerResource: () => null, - }); + }; const cf = makeCloudFunction(args); const test: Event = { context: { @@ -429,7 +430,9 @@ describe('Change', () => { it('should apply the customizer function to `before` and `after`', () => { function customizer(input: any) { - _.set(input, 'another', 'value'); + if (input) { + input.another = 'value'; + } return input as T; } const created = Change.fromJSON( diff --git a/spec/v1/providers/database.spec.ts b/spec/v1/providers/database.spec.ts index 30d24ea3c..f044e2ca2 100644 --- a/spec/v1/providers/database.spec.ts +++ b/spec/v1/providers/database.spec.ts @@ -21,7 +21,7 @@ // SOFTWARE. import { expect } from 'chai'; -import { apps as appsNamespace } from '../../../src/apps'; +import { getApp, setApp } from '../../../src/common/app'; import * as config from '../../../src/config'; import * as functions from '../../../src/index'; import * as database from '../../../src/providers/database'; @@ -59,12 +59,11 @@ describe('Database Functions', () => { (config as any).firebaseConfigCache = { databaseURL: 'https://subdomain.apse.firebasedatabase.app', }; - appsNamespace.init(); }); after(() => { (config as any).firebaseConfigCache = null; - delete appsNamespace.singleton; + setApp(undefined); }); it('should allow both region and runtime options to be set', () => { @@ -586,14 +585,13 @@ describe('Database Functions', () => { describe('DataSnapshot', () => { let subject: any; - const apps = new appsNamespace.Apps(); const populate = (data: any) => { const [instance, path] = database.extractInstanceAndPath( 'projects/_/instances/other-subdomain/refs/foo', 'firebaseio-staging.com' ); - subject = new database.DataSnapshot(data, path, apps.admin, instance); + subject = new database.DataSnapshot(data, path, getApp(), instance); }; describe('#ref: firebase.database.Reference', () => { @@ -903,7 +901,7 @@ describe('Database Functions', () => { const snapshot = new database.DataSnapshot( null, path, - apps.admin, + getApp(), instance ); expect(snapshot.key).to.be.null; diff --git a/spec/v1/providers/firestore.spec.ts b/spec/v1/providers/firestore.spec.ts index 3f9f07fbe..49fae4fef 100644 --- a/spec/v1/providers/firestore.spec.ts +++ b/spec/v1/providers/firestore.spec.ts @@ -21,8 +21,7 @@ // SOFTWARE. import { expect } from 'chai'; -import * as admin from 'firebase-admin'; -import * as _ from 'lodash'; +import { Timestamp } from 'firebase-admin/firestore'; import * as functions from '../../../src/index'; import * as firestore from '../../../src/providers/firestore'; @@ -41,18 +40,16 @@ describe('Firestore Functions', () => { context = context || {}; return { data, - context: _.merge( - { - eventId: '123', - timestamp: '2018-07-03T00:49:04.264Z', - eventType: 'google.firestore.document.create', - resource: { - name: 'projects/myproj/databases/(default)/documents/tests/test1', - service: 'service', - }, + context: { + eventId: '123', + timestamp: '2018-07-03T00:49:04.264Z', + eventType: 'google.firestore.document.create', + resource: { + name: 'projects/myproj/databases/(default)/documents/tests/test1', + service: 'service', }, - context - ), + ...context, + }, }; } @@ -521,7 +518,7 @@ describe('Firestore Functions', () => { value: raw, }) ); - expect(_.get(snapshot.data(), 'referenceVal').path).to.equal('doc1/id'); + expect(snapshot.data()?.referenceVal?.path).to.equal('doc1/id'); }); it('should parse timestamp values with precision to the millisecond', () => { @@ -536,7 +533,7 @@ describe('Firestore Functions', () => { }) ); expect(snapshot.data()).to.deep.equal({ - timestampVal: admin.firestore.Timestamp.fromDate( + timestampVal: Timestamp.fromDate( new Date('2017-06-13T00:58:40.349Z') ), }); @@ -554,9 +551,7 @@ describe('Firestore Functions', () => { }) ); expect(snapshot.data()).to.deep.equal({ - timestampVal: admin.firestore.Timestamp.fromDate( - new Date('2017-06-13T00:58:40Z') - ), + timestampVal: Timestamp.fromDate(new Date('2017-06-13T00:58:40Z')), }); }); diff --git a/src/apps.ts b/src/apps.ts deleted file mode 100644 index 163cd4795..000000000 --- a/src/apps.ts +++ /dev/null @@ -1,134 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2017 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 firebase from 'firebase-admin'; -import * as _ from 'lodash'; -import { firebaseConfig } from './config'; - -export function apps(): apps.Apps { - if (typeof apps.singleton === 'undefined') { - apps.init(); - } - return apps.singleton; -} - -export namespace apps { - /** @hidden */ - export const garbageCollectionInterval = 2 * 60 * 1000; - - /** @hidden */ - export function delay(delay: number) { - return new Promise((resolve) => { - setTimeout(resolve, delay); - }); - } - - export let singleton: apps.Apps; - - export let init = () => (singleton = new Apps()); - - export interface AuthMode { - admin: boolean; - variable?: any; - } - - /** @hidden */ - export interface RefCounter { - [appName: string]: number; - } - - export class Apps { - private _refCounter: RefCounter; - private _emulatedAdminApp?: firebase.app.App; - - constructor() { - this._refCounter = {}; - } - - _appAlive(appName: string): boolean { - try { - const app = firebase.app(appName); - return !_.get(app, 'isDeleted_'); - } catch (e) { - return false; - } - } - - _destroyApp(appName: string) { - if (!this._appAlive(appName)) { - return; - } - firebase - .app(appName) - .delete() - .catch(_.noop); - } - - retain() { - const increment = (n?: number) => { - return (n || 0) + 1; - }; - // Increment counter for admin because function might use event.data.ref - _.update(this._refCounter, '__admin__', increment); - } - - release() { - const decrement = (n: number) => { - return n - 1; - }; - return delay(garbageCollectionInterval).then(() => { - _.update(this._refCounter, '__admin__', decrement); - _.forEach(this._refCounter, (count, key) => { - if (count <= 0) { - this._destroyApp(key); - } - }); - }); - } - - get admin(): firebase.app.App { - if (this._emulatedAdminApp) { - return this._emulatedAdminApp; - } - - if (this._appAlive('__admin__')) { - return firebase.app('__admin__'); - } - return firebase.initializeApp(this.firebaseArgs, '__admin__'); - } - - /** - * This function allows the Firebase Emulator Suite to override the FirebaseApp instance - * used by the Firebase Functions SDK. Developers should never call this function for - * other purposes. - */ - setEmulatedAdminApp(app: firebase.app.App) { - this._emulatedAdminApp = app; - } - - private get firebaseArgs() { - return _.assign({}, firebaseConfig(), { - credential: firebase.credential.applicationDefault(), - }); - } - } -} diff --git a/src/cloud-functions.ts b/src/cloud-functions.ts index a362230fb..07c117d4a 100644 --- a/src/cloud-functions.ts +++ b/src/cloud-functions.ts @@ -21,7 +21,6 @@ // SOFTWARE. import { Request, Response } from 'express'; -import * as _ from 'lodash'; import { DEFAULT_FAILURE_POLICY, DeploymentOptions, @@ -43,9 +42,10 @@ import { ManifestEndpoint, ManifestRequiredAPI } from './runtime/manifest'; const WILDCARD_REGEX = new RegExp('{[^/{}]*}', 'g'); /** - * @hidden - * * Wire format for an event. + + * @hidden + * @alpha */ export interface Event { context: { @@ -54,6 +54,13 @@ export interface Event { eventType: string; resource: Resource; domain?: string; + auth?: { + variable?: { + uid?: string; + token?: string; + }; + admin: boolean; + }; }; data: any; } @@ -231,14 +238,21 @@ export namespace Change { const before = { ...after }; const masks = fieldMask.split(','); - masks.forEach((mask) => { - const val = _.get(sparseBefore, mask); + for (const mask of masks) { + const parts = mask.split('.'); + const head = parts[0]; + const tail = parts.slice(1).join('.'); + if (parts.length > 1) { + before[head] = applyFieldMask(sparseBefore?.[head], after[head], tail); + continue; + } + const val = sparseBefore?.[head]; if (typeof val === 'undefined') { - _.unset(before, mask); + delete before[mask]; } else { - _.set(before, mask, val); + before[mask] = val; } - }); + } return before; } @@ -374,8 +388,6 @@ export interface MakeCloudFunctionArgs { /** @hidden */ export function makeCloudFunction({ - after = () => {}, - before = () => {}, contextOnlyHandler, dataConstructor = (raw: Event) => raw.data, eventType, @@ -426,8 +438,6 @@ export function makeCloudFunction({ context.params = context.params || _makeParams(context, triggerResource); } - before(event); - let promise; if (labels && labels['deployment-scheduled']) { // Scheduled function do not have meaningful data, so exclude it @@ -439,15 +449,7 @@ export function makeCloudFunction({ if (typeof promise === 'undefined') { warn('Function returned undefined, expected Promise or value'); } - return Promise.resolve(promise) - .then((result) => { - after(event); - return result; - }) - .catch((err) => { - after(event); - return Promise.reject(err); - }); + return Promise.resolve(promise); }; Object.defineProperty(cloudFunction, '__trigger', { @@ -456,14 +458,15 @@ export function makeCloudFunction({ return {}; } - const trigger: any = _.assign(optionsToTrigger(options), { + const trigger: any = { + ...optionsToTrigger(options), eventTrigger: { resource: triggerResource(), eventType: legacyEventType || provider + '.' + eventType, service, }, - }); - if (!_.isEmpty(labels)) { + }; + if (!!labels && Object.keys(labels).length) { trigger.labels = { ...trigger.labels, ...labels }; } return trigger; @@ -519,7 +522,7 @@ export function makeCloudFunction({ function _makeParams( context: EventContext, triggerResourceGetter: () => string -): { [option: string]: any } { +): Record { if (context.params) { // In unit testing, user may directly provide `context.params`. return context.params; @@ -531,14 +534,16 @@ function _makeParams( const triggerResource = triggerResourceGetter(); const wildcards = triggerResource.match(WILDCARD_REGEX); const params: { [option: string]: any } = {}; - if (wildcards) { - const triggerResourceParts = _.split(triggerResource, '/'); - const eventResourceParts = _.split(context.resource.name, '/'); - _.forEach(wildcards, (wildcard) => { + + // Note: some tests don't set context.resource.name + const eventResourceParts = context?.resource?.name?.split?.('/'); + if (wildcards && eventResourceParts) { + const triggerResourceParts = triggerResource.split('/'); + for (const wildcard of wildcards) { const wildcardNoBraces = wildcard.slice(1, -1); - const position = _.indexOf(triggerResourceParts, wildcard); + const position = triggerResourceParts.indexOf(wildcard); params[wildcardNoBraces] = eventResourceParts[position]; - }); + } } return params; } @@ -549,17 +554,17 @@ function _makeAuth(event: Event, authType: string) { return null; } return { - uid: _.get(event, 'context.auth.variable.uid'), - token: _.get(event, 'context.auth.variable.token'), + uid: event.context?.auth?.variable?.uid, + token: event.context?.auth?.variable?.token, }; } /** @hidden */ function _detectAuthType(event: Event) { - if (_.get(event, 'context.auth.admin')) { + if (event.context?.auth?.admin) { return 'ADMIN'; } - if (_.has(event, 'context.auth.variable')) { + if (event.context?.auth?.variable) { return 'USER'; } return 'UNAUTHENTICATED'; diff --git a/src/common/app.ts b/src/common/app.ts new file mode 100644 index 000000000..4fa55961f --- /dev/null +++ b/src/common/app.ts @@ -0,0 +1,69 @@ +// The MIT License (MIT) +// +// Copyright (c) 2017 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 { + App, + applicationDefault, + deleteApp, + getApp as getAppNamed, + initializeApp, +} from 'firebase-admin/app'; +import { firebaseConfig } from '../config'; + +const APP_NAME = '__FIREBASE_FUNCTIONS_SDK__'; + +let cache: App; +export function getApp(): App { + if (typeof cache === 'undefined') { + try { + cache = getAppNamed(/* default */); + } catch {} + } + if (typeof cache === 'undefined') { + cache = initializeApp( + { + ...firebaseConfig(), + credential: applicationDefault(), + }, + APP_NAME + ); + } + return cache; +} + +/** + * This function allows the Firebase Emulator Suite to override the FirebaseApp instance + * used by the Firebase Functions SDK. Developers should never call this function for + * other purposes. + * N.B. For clarity for use in testing this name has no mention of emulation, but + * it must be exported from index as app.setEmulatedAdminApp or we break the emulator. + * We can remove this export when: + * A) We complete the new emulator and no longer depend on monkeypatching + * B) We tweak the CLI to look for different APIs to monkeypatch depending on versions. + * @alpha + */ +export function setApp(app?: App) { + if (cache?.name === APP_NAME) { + deleteApp(cache); + } + cache = app; +} diff --git a/src/common/providers/database.ts b/src/common/providers/database.ts index f14ce8b1d..d7fc84449 100644 --- a/src/common/providers/database.ts +++ b/src/common/providers/database.ts @@ -20,18 +20,19 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as firebase from 'firebase-admin'; +import { App } from 'firebase-admin/app'; +import * as database from 'firebase-admin/database'; import { firebaseConfig } from '../../config'; import { joinPath, pathParts } from '../../utilities/path'; /** * Interface representing a Firebase Realtime database data snapshot. */ -export class DataSnapshot implements firebase.database.DataSnapshot { +export class DataSnapshot implements database.DataSnapshot { public instance: string; /** @hidden */ - private _ref: firebase.database.Reference; + private _ref: database.Reference; /** @hidden */ private _path: string; @@ -45,7 +46,7 @@ export class DataSnapshot implements firebase.database.DataSnapshot { constructor( data: any, path?: string, // path is undefined for the database root - private app?: firebase.app.App, + private app?: App, instance?: string ) { const config = firebaseConfig(); @@ -75,7 +76,7 @@ export class DataSnapshot implements firebase.database.DataSnapshot { * to the database location where the triggering write occurred. Has * full read and write access. */ - get ref(): firebase.database.Reference { + get ref(): database.Reference { if (!this.app) { // may be unpopulated in user's unit tests throw new Error( @@ -84,7 +85,13 @@ export class DataSnapshot implements firebase.database.DataSnapshot { ); } if (!this._ref) { - this._ref = this.app.database(this.instance).ref(this._fullPath()); + let db: database.Database; + if (this.instance) { + db = database.getDatabaseWithUrl(this.instance, this.app); + } else { + db = database.getDatabase(this.app); + } + this._ref = db.ref(this._fullPath()); } return this._ref; } diff --git a/src/common/providers/https.ts b/src/common/providers/https.ts index fab4fe158..ed613f6d7 100644 --- a/src/common/providers/https.ts +++ b/src/common/providers/https.ts @@ -23,13 +23,14 @@ import * as cors from 'cors'; import * as express from 'express'; import { DecodedAppCheckToken } from 'firebase-admin/app-check'; -import { DecodedIdToken } from 'firebase-admin/auth'; 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 { getAppCheck } from 'firebase-admin/app-check'; +import { DecodedIdToken, getAuth } from 'firebase-admin/auth'; +import { getApp } from '../app'; import { isDebugFeatureEnabled } from '../debug'; import { TaskContext } from './tasks'; @@ -591,9 +592,7 @@ export async function checkAuthToken( if (isDebugFeatureEnabled('skipTokenVerification')) { authToken = unsafeDecodeIdToken(idToken); } else { - authToken = await apps() - .admin.auth() - .verifyIdToken(idToken); + authToken = await getAuth(getApp()).verifyIdToken(idToken); } ctx.auth = { uid: authToken.uid, @@ -622,9 +621,7 @@ async function checkAppCheckToken( const decodedToken = unsafeDecodeAppCheckToken(appCheck); appCheckData = { appId: decodedToken.app_id, token: decodedToken }; } else { - appCheckData = await apps() - .admin.appCheck() - .verifyToken(appCheck); + appCheckData = await getAppCheck(getApp()).verifyToken(appCheck); } ctx.app = appCheckData; return 'VALID'; diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index 948c73ce9..0650d9266 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -21,10 +21,10 @@ // SOFTWARE. import * as express from 'express'; -import * as firebase from 'firebase-admin'; +import * as auth from 'firebase-admin/auth'; import { logger } from '../..'; -import { apps } from '../../apps'; import { EventContext } from '../../cloud-functions'; +import { getApp } from '../app'; import { isDebugFeatureEnabled } from '../debug'; import { HttpsError, unsafeDecodeToken } from './https'; @@ -66,17 +66,17 @@ const EVENT_MAPPING: Record = { * The UserRecord passed to Cloud Functions is the same UserRecord that is returned by the Firebase Admin * SDK. */ -export type UserRecord = firebase.auth.UserRecord; +export type UserRecord = auth.UserRecord; /** * UserInfo that is part of the UserRecord */ -export type UserInfo = firebase.auth.UserInfo; +export type UserInfo = auth.UserInfo; /** * Helper class to create the user metadata in a UserRecord object */ -export class UserRecordMetadata implements firebase.auth.UserMetadata { +export class UserRecordMetadata implements auth.UserMetadata { constructor(public creationTime: string, public lastSignInTime: string) {} /** Returns a plain JavaScript object with the properties of UserRecordMetadata. */ @@ -825,7 +825,7 @@ export function wrapHandler( throw new HttpsError('invalid-argument', 'Bad Request'); } - if (!apps().admin.auth()._verifyAuthBlockingToken) { + if (!auth.getAuth(getApp())._verifyAuthBlockingToken) { throw new Error( 'Cannot validate Auth Blocking token. Please update Firebase Admin SDK to >= v10.1.0' ); @@ -835,8 +835,8 @@ export function wrapHandler( 'skipTokenVerification' ) ? unsafeDecodeAuthBlockingToken(req.body.data.jwt) - : await apps() - .admin.auth() + : await auth + .getAuth(getApp()) ._verifyAuthBlockingToken(req.body.data.jwt); const authUserRecord = parseAuthUserRecord(decodedPayload.user_record); diff --git a/src/common/providers/tasks.ts b/src/common/providers/tasks.ts index d84f7a585..a7e1b2a2a 100644 --- a/src/common/providers/tasks.ts +++ b/src/common/providers/tasks.ts @@ -21,7 +21,7 @@ // SOFTWARE. import * as express from 'express'; -import * as firebase from 'firebase-admin'; +import { DecodedIdToken } from 'firebase-admin/auth'; import * as logger from '../../logger'; import * as https from './https'; @@ -77,7 +77,7 @@ export interface RateLimits { /** Metadata about the authorization used to invoke a function. */ export interface AuthData { uid: string; - token: firebase.auth.DecodedIdToken; + token: DecodedIdToken; } /** Metadata about a call to a Task Queue function. */ diff --git a/src/function-builder.ts b/src/function-builder.ts index 1fd4efb05..d495c9d71 100644 --- a/src/function-builder.ts +++ b/src/function-builder.ts @@ -21,7 +21,6 @@ // SOFTWARE. import * as express from 'express'; -import * as _ from 'lodash'; import { CloudFunction, EventContext } from './cloud-functions'; import { @@ -53,7 +52,7 @@ import * as testLab from './providers/testLab'; function assertRuntimeOptionsValid(runtimeOptions: RuntimeOptions): boolean { if ( runtimeOptions.memory && - !_.includes(VALID_MEMORY_OPTIONS, runtimeOptions.memory) + !VALID_MEMORY_OPTIONS.includes(runtimeOptions.memory) ) { throw new Error( `The only valid memory allocation values are: ${VALID_MEMORY_OPTIONS.join( @@ -72,7 +71,7 @@ function assertRuntimeOptionsValid(runtimeOptions: RuntimeOptions): boolean { if ( runtimeOptions.ingressSettings && - !_.includes(INGRESS_SETTINGS_OPTIONS, runtimeOptions.ingressSettings) + !INGRESS_SETTINGS_OPTIONS.includes(runtimeOptions.ingressSettings) ) { throw new Error( `The only valid ingressSettings values are: ${INGRESS_SETTINGS_OPTIONS.join( @@ -83,8 +82,7 @@ function assertRuntimeOptionsValid(runtimeOptions: RuntimeOptions): boolean { if ( runtimeOptions.vpcConnectorEgressSettings && - !_.includes( - VPC_EGRESS_SETTINGS_OPTIONS, + !VPC_EGRESS_SETTINGS_OPTIONS.includes( runtimeOptions.vpcConnectorEgressSettings ) ) { @@ -95,28 +93,11 @@ function assertRuntimeOptionsValid(runtimeOptions: RuntimeOptions): boolean { ); } - if (runtimeOptions.failurePolicy !== undefined) { - if ( - _.isBoolean(runtimeOptions.failurePolicy) === false && - _.isObjectLike(runtimeOptions.failurePolicy) === false - ) { - throw new Error(`failurePolicy must be a boolean or an object.`); - } - - if (typeof runtimeOptions.failurePolicy === 'object') { - if ( - _.isObjectLike(runtimeOptions.failurePolicy.retry) === false || - _.isEmpty(runtimeOptions.failurePolicy.retry) === false - ) { - throw new Error('failurePolicy.retry must be an empty object.'); - } - } - } - + validateFailurePolicy(runtimeOptions.failurePolicy); if ( runtimeOptions.serviceAccount && runtimeOptions.serviceAccount !== 'default' && - !_.includes(runtimeOptions.serviceAccount, '@') + !runtimeOptions.serviceAccount.includes('@') ) { throw new Error( `serviceAccount must be set to 'default', a service account email, or '{serviceAccountName}@'` @@ -245,6 +226,20 @@ function assertRuntimeOptionsValid(runtimeOptions: RuntimeOptions): boolean { return true; } +function validateFailurePolicy(policy: any) { + if (typeof policy === 'boolean' || typeof policy === 'undefined') { + return; + } + if (typeof policy !== 'object') { + throw new Error(`failurePolicy must be a boolean or an object.`); + } + + const retry = policy.retry; + if (typeof retry !== 'object' || Object.keys(retry).length) { + throw new Error('failurePolicy.retry must be an empty object.'); + } +} + /** * Assert regions specified are valid. * @param regions list of regions. @@ -334,7 +329,10 @@ export class FunctionBuilder { */ runWith(runtimeOptions: RuntimeOptions): FunctionBuilder { if (assertRuntimeOptionsValid(runtimeOptions)) { - this.options = _.assign(this.options, runtimeOptions); + this.options = { + ...this.options, + ...runtimeOptions, + }; return this; } } diff --git a/src/handler-builder.ts b/src/handler-builder.ts index c976bf8e6..dcce26bcc 100644 --- a/src/handler-builder.ts +++ b/src/handler-builder.ts @@ -22,7 +22,6 @@ import * as express from 'express'; -import { apps } from './apps'; import { CloudFunction, EventContext, HttpsFunction } from './cloud-functions'; import * as analytics from './providers/analytics'; import * as auth from './providers/auth'; @@ -158,12 +157,12 @@ export class HandlerBuilder { get instance() { return { get ref() { - return new database.RefBuilder(apps(), () => null, {}); + return new database.RefBuilder(() => null, {}); }, }; }, get ref() { - return new database.RefBuilder(apps(), () => null, {}); + return new database.RefBuilder(() => null, {}); }, }; } diff --git a/src/index.ts b/src/index.ts index a77ba3ff9..e7b23b834 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,16 +32,13 @@ import * as storage from './providers/storage'; import * as tasks from './providers/tasks'; import * as testLab from './providers/testLab'; -import * as apps from './apps'; +import { setApp as setEmulatedAdminApp } from './common/app'; import { handler } from './handler-builder'; import * as logger from './logger'; import { setup } from './setup'; -const app = apps.apps(); - export { analytics, - app, auth, database, firestore, @@ -55,6 +52,8 @@ export { logger, }; +export const app = { setEmulatedAdminApp }; + // Exported root types: export * from './cloud-functions'; export * from './config'; diff --git a/src/providers/analytics.ts b/src/providers/analytics.ts index 2d57d6f5e..f5d68bc8c 100644 --- a/src/providers/analytics.ts +++ b/src/providers/analytics.ts @@ -20,8 +20,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as _ from 'lodash'; - import { CloudFunction, Event, @@ -143,7 +141,7 @@ export class AnalyticsEvent { // If there's an eventDim, there'll always be exactly one. const eventDim = wireFormat.eventDim[0]; copyField(eventDim, this, 'name'); - copyField(eventDim, this, 'params', (p) => _.mapValues(p, unwrapValue)); + copyField(eventDim, this, 'params', (p) => mapKeys(p, unwrapValue)); copyFieldTo(eventDim, this, 'valueInUsd', 'valueInUSD'); copyFieldTo(eventDim, this, 'date', 'reportingDate'); copyTimestampToString(eventDim, this, 'timestampMicros', 'logTime'); @@ -218,10 +216,19 @@ export class UserDimensions { 'firstOpenTime' ); this.userProperties = {}; // With no entries in the wire format, present an empty (as opposed to absent) map. - copyField(wireFormat, this, 'userProperties', (r) => - _.mapValues(r, (p) => new UserPropertyValue(p)) + copyField(wireFormat, this, 'userProperties', (r) => { + const entries = Object.entries(r).map(([k, v]) => [ + k, + new UserPropertyValue(v), + ]); + return Object.fromEntries(entries); + }); + copyField( + wireFormat, + this, + 'bundleInfo', + (r) => new ExportBundleInfo(r) as any ); - copyField(wireFormat, this, 'bundleInfo', (r) => new ExportBundleInfo(r)); // BUG(36000368) Remove when no longer necessary /* tslint:disable:no-string-literal */ @@ -242,7 +249,7 @@ export class UserPropertyValue { /** @hidden */ constructor(wireFormat: any) { - copyField(wireFormat, this, 'value', unwrapValueAsString); + copyField(wireFormat, this, 'value', unwrapValueAsString as any); copyTimestampToString(wireFormat, this, 'setTimestampUsec', 'setTime'); } } @@ -418,35 +425,66 @@ export class ExportBundleInfo { } /** @hidden */ -function copyFieldTo( - from: any, - to: T, - fromField: string, - toField: K, - transform: (val: any) => T[K] = _.identity +function copyFieldTo< + From extends object, + FromKey extends keyof From, + To extends object, + ToKey extends keyof To +>( + from: From, + to: To, + fromField: FromKey, + toField: ToKey, + transform?: (val: Required[FromKey]) => Required[ToKey] ): void { - if (from[fromField] !== undefined) { + if (typeof from[fromField] === 'undefined') { + return; + } + if (transform) { to[toField] = transform(from[fromField]); + return; } + to[toField] = from[fromField] as any; } /** @hidden */ -function copyField( - from: any, - to: T, - field: K, - transform: (val: any) => T[K] = _.identity +function copyField< + From extends object, + To extends Object, + Key extends keyof From & keyof To +>( + from: From, + to: To, + field: Key, + transform: (val: Required[Key]) => Required[Key] = (from) => + from as any ): void { - copyFieldTo(from, to, field as string, field, transform); + copyFieldTo(from, to, field, field, transform); } /** @hidden */ -function copyFields(from: any, to: T, fields: K[]): void { +function copyFields< + From extends object, + To extends object, + Key extends keyof From & keyof To +>(from: From, to: To, fields: Key[]): void { for (const field of fields) { copyField(from, to, field); } } +type TransformedObject< + Obj extends Object, + Transform extends (key: keyof Obj) => any +> = { [key in keyof Obj]: ReturnType }; +function mapKeys any>( + obj: Obj, + transform: Transform +): TransformedObject { + const entries = Object.entries(obj).map(([k, v]) => [k, transform(v)]); + return Object.fromEntries(entries); +} + // The incoming payload will have fields like: // { // 'myInt': { @@ -478,8 +516,8 @@ function copyFields(from: any, to: T, fields: K[]): void { // 'unwrapValue' method just below. /** @hidden */ function unwrapValueAsString(wrapped: any): string { - const key: string = _.keys(wrapped)[0]; - return _.toString(wrapped[key]); + const key: string = Object.keys(wrapped)[0]; + return wrapped[key].toString(); } // Ditto as the method above, but returning the values in the idiomatic JavaScript type (string for strings, @@ -500,9 +538,9 @@ const xValueNumberFields = ['intValue', 'floatValue', 'doubleValue']; /** @hidden */ function unwrapValue(wrapped: any): any { - const key: string = _.keys(wrapped)[0]; + const key: string = Object.keys(wrapped)[0]; const value: string = unwrapValueAsString(wrapped); - return _.includes(xValueNumberFields, key) ? _.toNumber(value) : value; + return xValueNumberFields.includes(key) ? Number(value) : value; } // The JSON payload delivers timestamp fields as strings of timestamps denoted in microseconds. @@ -516,7 +554,7 @@ function copyTimestampToMillis( toName: K ) { if (from[fromName] !== undefined) { - to[toName] = _.round(from[fromName] / 1000) as any; + to[toName] = Math.round(from[fromName] / 1000) as any; } } diff --git a/src/providers/database.ts b/src/providers/database.ts index 5b7dc59f5..8dba83ffc 100644 --- a/src/providers/database.ts +++ b/src/providers/database.ts @@ -20,7 +20,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { apps } from '../apps'; import { Change, CloudFunction, @@ -28,6 +27,7 @@ import { EventContext, makeCloudFunction, } from '../cloud-functions'; +import { getApp } from '../common/app'; import { DataSnapshot } from '../common/providers/database'; import { firebaseConfig } from '../config'; import { DeploymentOptions } from '../function-configuration'; @@ -117,7 +117,6 @@ export class InstanceBuilder { ref(path: string): RefBuilder { const normalized = normalizePath(path); return new RefBuilder( - apps(), () => `projects/_/instances/${this.instance}/refs/${normalized}`, this.options ); @@ -161,7 +160,7 @@ export function _refWithOptions( return `projects/_/instances/${instance}/refs/${normalized}`; }; - return new RefBuilder(apps(), resourceGetter, options); + return new RefBuilder(resourceGetter, options); } /** @@ -172,7 +171,6 @@ export function _refWithOptions( export class RefBuilder { /** @hidden */ constructor( - private apps: apps.Apps, private triggerResource: () => string, private options: DeploymentOptions ) {} @@ -231,12 +229,7 @@ export class RefBuilder { raw.context.resource.name, raw.context.domain ); - return new DataSnapshot( - raw.data.delta, - path, - this.apps.admin, - dbInstance - ); + return new DataSnapshot(raw.data.delta, path, getApp(), dbInstance); }; return this.onOperation(handler, 'ref.create', dataConstructor); } @@ -260,7 +253,7 @@ export class RefBuilder { raw.context.resource.name, raw.context.domain ); - return new DataSnapshot(raw.data.data, path, this.apps.admin, dbInstance); + return new DataSnapshot(raw.data.data, path, getApp(), dbInstance); }; return this.onOperation(handler, 'ref.delete', dataConstructor); } @@ -278,8 +271,6 @@ export class RefBuilder { legacyEventType: `providers/${provider}/eventTypes/${eventType}`, triggerResource: this.triggerResource, dataConstructor, - before: (event) => this.apps.retain(), - after: (event) => this.apps.release(), options: this.options, }); } @@ -289,16 +280,11 @@ export class RefBuilder { raw.context.resource.name, raw.context.domain ); - const before = new DataSnapshot( - raw.data.data, - path, - this.apps.admin, - dbInstance - ); + const before = new DataSnapshot(raw.data.data, path, getApp(), dbInstance); const after = new DataSnapshot( applyChange(raw.data.data, raw.data.delta), path, - this.apps.admin, + getApp(), dbInstance ); return { diff --git a/src/providers/firestore.ts b/src/providers/firestore.ts index 620040d2b..e159d62bf 100644 --- a/src/providers/firestore.ts +++ b/src/providers/firestore.ts @@ -20,11 +20,9 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as firebase from 'firebase-admin'; -import * as _ from 'lodash'; +import * as firestore from 'firebase-admin/firestore'; import { posix } from 'path'; -import { apps } from '../apps'; import { Change, CloudFunction, @@ -32,6 +30,7 @@ import { EventContext, makeCloudFunction, } from '../cloud-functions'; +import { getApp } from '../common/app'; import { dateToTimestampProto } from '../encoder'; import { DeploymentOptions } from '../function-configuration'; import * as logger from '../logger'; @@ -43,8 +42,8 @@ export const service = 'firestore.googleapis.com'; /** @hidden */ export const defaultDatabase = '(default)'; let firestoreInstance: any; -export type DocumentSnapshot = firebase.firestore.DocumentSnapshot; -export type QueryDocumentSnapshot = firebase.firestore.QueryDocumentSnapshot; +export type DocumentSnapshot = firestore.DocumentSnapshot; +export type QueryDocumentSnapshot = firestore.QueryDocumentSnapshot; /** * Select the Firestore document to listen to for events. @@ -130,19 +129,20 @@ export class NamespaceBuilder { } function _getValueProto(data: any, resource: string, valueFieldName: string) { - if (_.isEmpty(_.get(data, valueFieldName))) { + const value = data?.[valueFieldName]; + if ( + typeof value === 'undefined' || + value === null || + (typeof value === 'object' && !Object.keys(value).length) + ) { // Firestore#snapshot_ takes resource string instead of proto for a non-existent snapshot return resource; } const proto = { - fields: _.get(data, [valueFieldName, 'fields'], {}), - createTime: dateToTimestampProto( - _.get(data, [valueFieldName, 'createTime']) - ), - updateTime: dateToTimestampProto( - _.get(data, [valueFieldName, 'updateTime']) - ), - name: _.get(data, [valueFieldName, 'name'], resource), + fields: value?.fields || {}, + createTime: dateToTimestampProto(value?.createTime), + updateTime: dateToTimestampProto(value?.updateTime), + name: value?.name || resource, }; return proto; } @@ -150,7 +150,7 @@ function _getValueProto(data: any, resource: string, valueFieldName: string) { /** @hidden */ export function snapshotConstructor(event: Event): DocumentSnapshot { if (!firestoreInstance) { - firestoreInstance = firebase.firestore(apps().admin); + firestoreInstance = firestore.getFirestore(getApp()); } const valueProto = _getValueProto( event.data, @@ -158,8 +158,7 @@ export function snapshotConstructor(event: Event): DocumentSnapshot { 'value' ); let timeString = - _.get(event, 'data.value.readTime') ?? - _.get(event, 'data.value.updateTime'); + event?.data?.value?.readTime ?? event?.data?.value?.updateTime; if (!timeString) { logger.warn('Snapshot has no readTime. Using now()'); @@ -174,16 +173,14 @@ export function snapshotConstructor(event: Event): DocumentSnapshot { // TODO remove this function when wire format changes to new format export function beforeSnapshotConstructor(event: Event): DocumentSnapshot { if (!firestoreInstance) { - firestoreInstance = firebase.firestore(apps().admin); + firestoreInstance = firestore.getFirestore(getApp()); } const oldValueProto = _getValueProto( event.data, event.context.resource.name, 'oldValue' ); - const oldReadTime = dateToTimestampProto( - _.get(event, 'data.oldValue.readTime') - ); + const oldReadTime = dateToTimestampProto(event?.data?.oldValue?.readTime); return firestoreInstance.snapshot_(oldValueProto, oldReadTime, 'json'); } diff --git a/src/providers/remoteConfig.ts b/src/providers/remoteConfig.ts index f15716c17..9cf54d686 100644 --- a/src/providers/remoteConfig.ts +++ b/src/providers/remoteConfig.ts @@ -20,8 +20,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as _ from 'lodash'; - import { CloudFunction, EventContext, diff --git a/src/providers/testLab.ts b/src/providers/testLab.ts index 86398c5c9..e56429de2 100644 --- a/src/providers/testLab.ts +++ b/src/providers/testLab.ts @@ -20,8 +20,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as _ from 'lodash'; - import { CloudFunction, Event, @@ -129,12 +127,15 @@ export class ClientInfo { details: { [key: string]: string }; /** @internal */ - constructor(data?: any) { - this.name = _.get(data, 'name', ''); + constructor(data?: { + name: string; + clientInfoDetails?: Array<{ key: string; value?: string }>; + }) { + this.name = data?.name || ''; this.details = {}; - _.forEach(_.get(data, 'clientInfoDetails'), (detail: any) => { + for (const detail of data?.clientInfoDetails || []) { this.details[detail.key] = detail.value || ''; - }); + } } } @@ -157,13 +158,10 @@ export class ResultStorage { /** @internal */ constructor(data?: any) { - this.gcsPath = _.get(data, 'googleCloudStorage.gcsPath'); - this.toolResultsHistoryId = _.get(data, 'toolResultsHistory.historyId'); - this.toolResultsExecutionId = _.get( - data, - 'toolResultsExecution.executionId' - ); - this.resultsUrl = _.get(data, 'resultsUrl'); + this.gcsPath = data?.googleCloudStorage?.gcsPath; + this.toolResultsHistoryId = data?.toolResultsHistory?.historyId; + this.toolResultsExecutionId = data?.toolResultsExecution?.executionId; + this.resultsUrl = data?.resultsUrl; } } diff --git a/src/utils.ts b/src/utils.ts index e41b1e1fb..2096607e2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -20,25 +20,36 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -/** @hidden */ -import * as _ from 'lodash'; +function isObject(obj: any): boolean { + return typeof obj === 'object' && !!obj; +} +/** @hidden */ export function applyChange(src: any, dest: any) { // if not mergeable, don't merge - if (!_.isPlainObject(dest) || !_.isPlainObject(src)) { + if (!isObject(dest) || !isObject(src)) { return dest; } - return pruneNulls(_.merge({}, src, dest)); + return merge(src, dest); } -export function pruneNulls(obj: any) { - for (const key in obj) { - if (obj[key] === null) { - delete obj[key]; - } else if (_.isPlainObject(obj[key])) { - pruneNulls(obj[key]); +function merge( + src: Record, + dest: Record +): Record { + const res: Record = {}; + const keys = new Set([...Object.keys(src), ...Object.keys(dest)]); + + for (const key of keys.values()) { + if (key in dest) { + if (dest[key] === null) { + continue; + } + res[key] = applyChange(src[key], dest[key]); + } else if (src[key] !== null) { + res[key] = src[key]; } } - return obj; + return res; } diff --git a/src/v2/providers/database.ts b/src/v2/providers/database.ts index e0b410422..372a949e8 100644 --- a/src/v2/providers/database.ts +++ b/src/v2/providers/database.ts @@ -20,8 +20,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { apps } from '../../apps'; import { Change } from '../../cloud-functions'; +import { getApp } from '../../common/app'; import { DataSnapshot } from '../../common/providers/database'; import { ManifestEndpoint } from '../../runtime/manifest'; import { normalizePath } from '../../utilities/path'; @@ -274,7 +274,7 @@ function makeDatabaseEvent( instance: string, params: Record ): DatabaseEvent { - const snapshot = new DataSnapshot(data, event.ref, apps().admin, instance); + const snapshot = new DataSnapshot(data, event.ref, getApp(), instance); const databaseEvent: DatabaseEvent = { ...event, firebaseDatabaseHost: event.firebasedatabasehost, @@ -294,13 +294,13 @@ function makeChangedDatabaseEvent( const before = new DataSnapshot( event.data.data, event.ref, - apps().admin, + getApp(), instance ); const after = new DataSnapshot( applyChange(event.data.data, event.data.delta), event.ref, - apps().admin, + getApp(), instance ); const databaseEvent: DatabaseEvent> = { diff --git a/tsconfig.release.json b/tsconfig.release.json index b98955ee5..93bbefe3a 100644 --- a/tsconfig.release.json +++ b/tsconfig.release.json @@ -1,13 +1,13 @@ { "compilerOptions": { "declaration": true, - "lib": ["es2018"], + "lib": ["es2019"], "module": "commonjs", "noImplicitAny": false, "noUnusedLocals": true, "outDir": "lib", "stripInternal": true, - "target": "es2018", + "target": "es2019", "typeRoots": ["./node_modules/@types"] }, "files": [