diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb..b174a17fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Adds RTDB Triggers for v2 functions (#1127) diff --git a/package.json b/package.json index 026c79db3..03f56c851 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,8 @@ "./v2/alerts/billing": "./lib/v2/providers/alerts/billing.js", "./v2/alerts/crashlytics": "./lib/v2/providers/alerts/crashlytics.js", "./v2/eventarc": "./lib/v2/providers/eventarc.js", - "./v2/identity": "./lib/v2/providers/identity.js" + "./v2/identity": "./lib/v2/providers/identity.js", + "./v2/database": "./lib/v2/providers/database.js" }, "typesVersions": { "*": { @@ -126,6 +127,9 @@ "v2/base": [ "lib/v2/base" ], + "v2/database": [ + "lib/v2/providers/database" + ], "v2/eventarc": [ "lib/v2/providers/eventarc" ], diff --git a/spec/utilities/path-pattern.spec.ts b/spec/utilities/path-pattern.spec.ts new file mode 100644 index 000000000..8fe513284 --- /dev/null +++ b/spec/utilities/path-pattern.spec.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 { expect } from 'chai'; +import * as pathPattern from '../../src/utilities/path-pattern'; + +describe('path-pattern', () => { + describe('trimParam', () => { + it('should trim a capture param without equals', () => { + expect(pathPattern.trimParam('{something}')).to.equal('something'); + }); + + it('should trim a capture param with equals', () => { + expect(pathPattern.trimParam('{something=*}')).to.equal('something'); + }); + }); + + describe('extractMatches', () => { + it('should parse without multi segment', () => { + const pp = new pathPattern.PathPattern('{a}/something/else/{b}/end/{c}'); + + expect( + pp.extractMatches('match_a/something/else/match_b/end/match_c') + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + c: 'match_c', + }); + }); + + it('should parse multi segment with params after', () => { + const pp = new pathPattern.PathPattern( + 'something/**/else/{a}/hello/{b}/world' + ); + + expect( + pp.extractMatches('something/is/a/thing/else/nothing/hello/user/world') + ).to.deep.equal({ + a: 'nothing', + b: 'user', + }); + }); + + it('should parse multi segment param with params after', () => { + const pp = new pathPattern.PathPattern( + 'something/{path=**}/else/{a}/hello/{b}/world' + ); + + expect( + pp.extractMatches('something/is/a/thing/else/nothing/hello/user/world') + ).to.deep.equal({ + path: 'is/a/thing', + a: 'nothing', + b: 'user', + }); + }); + + it('should parse multi segment with params before', () => { + const pp = new pathPattern.PathPattern('{a}/something/{b}/**/end'); + + expect( + pp.extractMatches( + 'match_a/something/match_b/thing/else/nothing/hello/user/end' + ) + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + }); + }); + + it('should parse multi segment param with params before', () => { + const pp = new pathPattern.PathPattern('{a}/something/{b}/{path=**}/end'); + + expect( + pp.extractMatches( + 'match_a/something/match_b/thing/else/nothing/hello/user/end' + ) + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + path: 'thing/else/nothing/hello/user', + }); + }); + + it('should parse multi segment with params before and after', () => { + const pp = new pathPattern.PathPattern('{a}/something/**/{b}/end'); + + expect( + pp.extractMatches( + 'match_a/something/thing/else/nothing/hello/user/match_b/end' + ) + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + }); + }); + + it('should parse multi segment param with params before', () => { + const pp = new pathPattern.PathPattern('{a}/something/{path=**}/{b}/end'); + + expect( + pp.extractMatches( + 'match_a/something/thing/else/nothing/hello/user/match_b/end' + ) + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + path: 'thing/else/nothing/hello/user', + }); + }); + + // handle an instance param + it('should parse an instance', () => { + const pp = new pathPattern.PathPattern('{a}-something-{b}-else-{c}'); + + expect( + pp.extractMatches('match_a-something-match_b-else-match_c') + ).to.deep.equal({}); + + const anotherPP = new pathPattern.PathPattern('{a}'); + + expect(anotherPP.extractMatches('match_a')).to.deep.equal({ + a: 'match_a', + }); + }); + }); +}); diff --git a/spec/v1/providers/database.spec.ts b/spec/v1/providers/database.spec.ts index 304c1d1fd..30d24ea3c 100644 --- a/spec/v1/providers/database.spec.ts +++ b/spec/v1/providers/database.spec.ts @@ -639,10 +639,6 @@ describe('Database Functions', () => { expect(subject.val()).to.equal(0); populate({ myKey: 0 }); expect(subject.val()).to.deep.equal({ myKey: 0 }); - - // Null values are still reported as null. - populate({ myKey: null }); - expect(subject.val()).to.deep.equal({ myKey: null }); }); // Regression test: .val() was returning array of nulls when there's a property called length (BUG#37683995) @@ -650,6 +646,45 @@ describe('Database Functions', () => { populate({ length: 3, foo: 'bar' }); expect(subject.val()).to.deep.equal({ length: 3, foo: 'bar' }); }); + + it('should deal with null-values appropriately', () => { + populate(null); + expect(subject.val()).to.be.null; + + populate({ myKey: null }); + expect(subject.val()).to.be.null; + }); + + it('should deal with empty object values appropriately', () => { + populate({}); + expect(subject.val()).to.be.null; + + populate({ myKey: {} }); + expect(subject.val()).to.be.null; + + populate({ myKey: { child: null } }); + expect(subject.val()).to.be.null; + }); + + it('should deal with empty array values appropriately', () => { + populate([]); + expect(subject.val()).to.be.null; + + populate({ myKey: [] }); + expect(subject.val()).to.be.null; + + populate({ myKey: [null] }); + expect(subject.val()).to.be.null; + + populate({ myKey: [{}] }); + expect(subject.val()).to.be.null; + + populate({ myKey: [{ myKey: null }] }); + expect(subject.val()).to.be.null; + + populate({ myKey: [{ myKey: {} }] }); + expect(subject.val()).to.be.null; + }); }); describe('#child(): DataSnapshot', () => { @@ -676,14 +711,37 @@ describe('Database Functions', () => { }); it('should be false for a non-existent value', () => { - populate({ a: { b: 'c' } }); + populate({ a: { b: 'c', nullChild: null } }); expect(subject.child('d').exists()).to.be.false; + expect(subject.child('nullChild').exists()).to.be.false; }); it('should be false for a value pathed beyond a leaf', () => { populate({ a: { b: 'c' } }); expect(subject.child('a/b/c').exists()).to.be.false; }); + + it('should be false for an empty object value', () => { + populate({ a: {} }); + expect(subject.child('a').exists()).to.be.false; + + populate({ a: { child: null } }); + expect(subject.child('a').exists()).to.be.false; + + populate({ a: { child: {} } }); + expect(subject.child('a').exists()).to.be.false; + }); + + it('should be false for an empty array value', () => { + populate({ a: [] }); + expect(subject.child('a').exists()).to.be.false; + + populate({ a: [null] }); + expect(subject.child('a').exists()).to.be.false; + + populate({ a: [{}] }); + expect(subject.child('a').exists()).to.be.false; + }); }); describe('#forEach(action: (a: DataSnapshot) => boolean): boolean', () => { @@ -712,6 +770,17 @@ describe('Database Functions', () => { expect(subject.forEach(counter)).to.equal(false); expect(count).to.eq(0); + + populate({ + a: 'foo', + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); + count = 0; + + expect(subject.forEach(counter)).to.equal(false); + expect(count).to.eq(1); }); it('should cancel further enumeration if callback returns true', () => { @@ -751,13 +820,51 @@ describe('Database Functions', () => { describe('#numChildren()', () => { it('should be key count for objects', () => { - populate({ a: 'b', c: 'd' }); + populate({ + a: 'b', + c: 'd', + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); expect(subject.numChildren()).to.eq(2); }); it('should be 0 for non-objects', () => { populate(23); expect(subject.numChildren()).to.eq(0); + + populate({ + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); + expect(subject.numChildren()).to.eq(0); + }); + }); + + describe('#hasChildren()', () => { + it('should true for objects', () => { + populate({ + a: 'b', + c: 'd', + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); + expect(subject.hasChildren()).to.be.true; + }); + + it('should be false for non-objects', () => { + populate(23); + expect(subject.hasChildren()).to.be.false; + + populate({ + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); + expect(subject.hasChildren()).to.be.false; }); }); @@ -769,9 +876,17 @@ describe('Database Functions', () => { }); it('should return false if a child is missing', () => { - populate({ a: 'b' }); + populate({ + a: 'b', + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); expect(subject.hasChild('c')).to.be.false; expect(subject.hasChild('a/b')).to.be.false; + expect(subject.hasChild('nullChild')).to.be.false; + expect(subject.hasChild('emptyObjectChild')).to.be.false; + expect(subject.hasChild('emptyArrayChild')).to.be.false; }); }); @@ -801,11 +916,21 @@ describe('Database Functions', () => { describe('#toJSON(): Object', () => { it('should return the current value', () => { - populate({ a: 'b' }); + populate({ + a: 'b', + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); expect(subject.toJSON()).to.deep.equal(subject.val()); }); it('should be stringifyable', () => { - populate({ a: 'b' }); + populate({ + a: 'b', + nullChild: null, + emptyObjectChild: {}, + emptyArrayChild: [], + }); expect(JSON.stringify(subject)).to.deep.equal('{"a":"b"}'); }); }); diff --git a/spec/v2/providers/database.spec.ts b/spec/v2/providers/database.spec.ts new file mode 100644 index 000000000..58d4285ab --- /dev/null +++ b/spec/v2/providers/database.spec.ts @@ -0,0 +1,569 @@ +// 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 { PathPattern } from '../../../src/utilities/path-pattern'; +import * as database from '../../../src/v2/providers/database'; + +const RAW_RTDB_EVENT: database.RawRTDBCloudEvent = { + data: { + ['@type']: + 'type.googleapis.com/google.events.firebase.database.v1.ReferenceEventData', + data: {}, + delta: {}, + }, + firebasedatabasehost: 'firebaseio.com', + instance: 'my-instance', + ref: 'foo/bar', + location: 'us-central1', + id: 'id', + source: 'source', + specversion: '1.0', + time: 'time', + type: 'type', +}; + +describe('database', () => { + describe('makeParams', () => { + it('should make params with basic path', () => { + const event: database.RawRTDBCloudEvent = { + ...RAW_RTDB_EVENT, + ref: 'match_a/something/else/nothing/end/match_b', + }; + + expect( + database.makeParams( + event, + new PathPattern('{a}/something/else/*/end/{b}'), + new PathPattern('*') + ) + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + }); + }); + + it('should make params with multi segment path', () => { + const event: database.RawRTDBCloudEvent = { + ...RAW_RTDB_EVENT, + ref: 'something/is/a/thing/else/match_a/hello/match_b/world', + }; + + expect( + database.makeParams( + event, + new PathPattern('something/**/else/{a}/hello/{b}/world'), + new PathPattern('*') + ) + ).to.deep.equal({ + a: 'match_a', + b: 'match_b', + }); + }); + + it('should make params with multi segment path capture', () => { + const event: database.RawRTDBCloudEvent = { + ...RAW_RTDB_EVENT, + ref: 'something/is/a/thing/else/match_a/hello/match_b/world', + }; + + expect( + database.makeParams( + event, + new PathPattern('something/{path=**}/else/{a}/hello/{b}/world'), + new PathPattern('*') + ) + ).to.deep.equal({ + path: 'is/a/thing', + a: 'match_a', + b: 'match_b', + }); + }); + + it('should make params for a full path and instance', () => { + const event: database.RawRTDBCloudEvent = { + ...RAW_RTDB_EVENT, + ref: 'something/is/a/thing/else/match_a/hello/match_b/world', + }; + + expect( + database.makeParams( + event, + new PathPattern('something/{path=**}/else/{a}/hello/{b}/world'), + new PathPattern('{inst}') + ) + ).to.deep.equal({ + path: 'is/a/thing', + a: 'match_a', + b: 'match_b', + inst: 'my-instance', + }); + }); + }); + + describe('getOpts', () => { + it('should return opts when passed in a path', () => { + expect(database.getOpts('/foo/{bar}/')).to.deep.equal({ + path: 'foo/{bar}', + instance: '*', + opts: {}, + }); + }); + + it('should return opts when passed in an options object', () => { + expect( + database.getOpts({ + ref: '/foo/{bar}/', + instance: '{inst}', + region: 'us-central1', + }) + ).to.deep.equal({ + path: 'foo/{bar}', + instance: '{inst}', + opts: { + region: 'us-central1', + }, + }); + }); + }); + + describe('makeEndpoint', () => { + it('should create an endpoint with an instance wildcard', () => { + const ep = database.makeEndpoint( + database.writtenEventType, + { + region: 'us-central1', + labels: { 1: '2' }, + }, + new PathPattern('foo/bar'), + new PathPattern('{inst}') + ); + + expect(ep).to.deep.equal({ + platform: 'gcfv2', + labels: { + 1: '2', + }, + region: ['us-central1'], + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/bar', + instance: '{inst}', + }, + retry: false, + }, + }); + }); + + it('should create an endpoint without an instance wildcard', () => { + const ep = database.makeEndpoint( + database.writtenEventType, + { + region: 'us-central1', + labels: { 1: '2' }, + }, + new PathPattern('foo/bar'), + new PathPattern('my-instance') + ); + + expect(ep).to.deep.equal({ + platform: 'gcfv2', + labels: { + 1: '2', + }, + region: ['us-central1'], + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/bar', + }, + retry: false, + }, + }); + }); + }); + + describe('onChangedOperation', () => { + it('should create a function for a written event', () => { + const func = database.onChangedOperation( + database.writtenEventType, + '/foo/{bar}/', + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a function for a updated event', () => { + const func = database.onChangedOperation( + database.updatedEventType, + '/foo/{bar}/', + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.updatedEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a complex function', () => { + const func = database.onChangedOperation( + database.writtenEventType, + { + ref: '/foo/{path=**}/{bar}/', + instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, + }, + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, + }, + }); + }); + }); + + describe('onOperation', () => { + it('should create a function for a created event', () => { + const func = database.onOperation( + database.createdEventType, + '/foo/{bar}/', + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.createdEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a function for a deleted event', () => { + const func = database.onOperation( + database.deletedEventType, + '/foo/{bar}/', + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.deletedEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a complex function', () => { + const func = database.onOperation( + database.createdEventType, + { + ref: '/foo/{path=**}/{bar}/', + instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, + }, + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.createdEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, + }, + }); + }); + }); + + describe('onRefWritten', () => { + it('should create a function with a reference', () => { + const func = database.onRefWritten('/foo/{bar}/', (event) => 2); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a function with opts', () => { + const func = database.onRefWritten( + { + ref: '/foo/{path=**}/{bar}/', + instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, + }, + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.writtenEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, + }, + }); + }); + }); + + describe('onRefCreated', () => { + it('should create a function with a reference', () => { + const func = database.onRefCreated('/foo/{bar}/', (event) => 2); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.createdEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a function with opts', () => { + const func = database.onRefCreated( + { + ref: '/foo/{path=**}/{bar}/', + instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, + }, + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.createdEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, + }, + }); + }); + }); + + describe('onRefUpdated', () => { + it('should create a function with a reference', () => { + const func = database.onRefUpdated('/foo/{bar}/', (event) => 2); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.updatedEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a function with opts', () => { + const func = database.onRefUpdated( + { + ref: '/foo/{path=**}/{bar}/', + instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, + }, + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.updatedEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, + }, + }); + }); + }); + + describe('onRefDeleted', () => { + it('should create a function with a reference', () => { + const func = database.onRefDeleted('/foo/{bar}/', (event) => 2); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: database.deletedEventType, + eventFilters: {}, + eventFilterPathPatterns: { + ref: 'foo/{bar}', + instance: '*', + }, + retry: false, + }, + }); + }); + + it('should create a function with opts', () => { + const func = database.onRefDeleted( + { + ref: '/foo/{path=**}/{bar}/', + instance: 'my-instance', + region: 'us-central1', + cpu: 'gcf_gen1', + minInstances: 2, + }, + (event) => 2 + ); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + cpu: 'gcf_gen1', + minInstances: 2, + region: ['us-central1'], + labels: {}, + eventTrigger: { + eventType: database.deletedEventType, + eventFilters: { + instance: 'my-instance', + }, + eventFilterPathPatterns: { + ref: 'foo/{path=**}/{bar}', + }, + retry: false, + }, + }); + }); + }); +}); diff --git a/src/common/providers/database.ts b/src/common/providers/database.ts new file mode 100644 index 000000000..f14ce8b1d --- /dev/null +++ b/src/common/providers/database.ts @@ -0,0 +1,338 @@ +// 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 firebase from 'firebase-admin'; +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 { + public instance: string; + + /** @hidden */ + private _ref: firebase.database.Reference; + + /** @hidden */ + private _path: string; + + /** @hidden */ + private _data: any; + + /** @hidden */ + private _childPath: string; + + constructor( + data: any, + path?: string, // path is undefined for the database root + private app?: firebase.app.App, + instance?: string + ) { + const config = firebaseConfig(); + if (app?.options?.databaseURL?.startsWith('http:')) { + // In this case we're dealing with an emulator + this.instance = app.options.databaseURL; + } else if (instance) { + // SDK always supplies instance, but user's unit tests may not + this.instance = instance; + } else if (app) { + this.instance = app.options.databaseURL; + } else if (config.databaseURL) { + this.instance = config.databaseURL; + } else if (process.env.GCLOUD_PROJECT) { + this.instance = + 'https://' + + process.env.GCLOUD_PROJECT + + '-default-rtdb.firebaseio.com'; + } + + this._path = path; + this._data = data; + } + + /** + * Returns a [`Reference`](/docs/reference/admin/node/admin.database.Reference) + * to the database location where the triggering write occurred. Has + * full read and write access. + */ + get ref(): firebase.database.Reference { + if (!this.app) { + // may be unpopulated in user's unit tests + throw new Error( + 'Please supply a Firebase app in the constructor for DataSnapshot' + + ' in order to use the .ref method.' + ); + } + if (!this._ref) { + this._ref = this.app.database(this.instance).ref(this._fullPath()); + } + return this._ref; + } + + /** + * The key (last part of the path) of the location of this `DataSnapshot`. + * + * The last token in a database location is considered its key. For example, + * "ada" is the key for the `/users/ada/` node. Accessing the key on any + * `DataSnapshot` returns the key for the location that generated it. + * However, accessing the key on the root URL of a database returns `null`. + */ + get key(): string | null { + const segments = pathParts(this._fullPath()); + const last = segments[segments.length - 1]; + return !last || last === '' ? null : last; + } + + /** + * Extracts a JavaScript value from a `DataSnapshot`. + * + * Depending on the data in a `DataSnapshot`, the `val()` method may return a + * scalar type (string, number, or boolean), an array, or an object. It may also + * return `null`, indicating that the `DataSnapshot` is empty (contains no + * data). + * + * @return The snapshot's contents as a JavaScript value (Object, + * Array, string, number, boolean, or `null`). + */ + val(): any { + const parts = pathParts(this._childPath); + let source = this._data; + if (parts.length) { + for (const part of parts) { + source = source[part]; + } + } + const node = source ?? null; + + return this._checkAndConvertToArray(node); + } + + /** + * Exports the entire contents of the `DataSnapshot` as a JavaScript object. + * + * @return The contents of the `DataSnapshot` as a JavaScript value + * (Object, Array, string, number, boolean, or `null`). + */ + exportVal(): any { + return this.val(); + } + + /** + * Gets the priority value of the data in this `DataSnapshot`. + * + * As an alternative to using priority, applications can order collections by + * ordinary properties. See [Sorting and filtering + * data](/docs/database/web/lists-of-data#sorting_and_filtering_data). + * + * @return The priority value of the data. + */ + getPriority(): string | number | null { + return 0; + } + + /** + * Returns `true` if this `DataSnapshot` contains any data. It is slightly more + * efficient than using `snapshot.val() !== null`. + * + * @return `true` if this `DataSnapshot` contains any data; otherwise, `false`. + */ + exists(): boolean { + const val = this.val(); + if (!val || val === null) { + return false; + } + if (typeof val === 'object' && Object.keys(val).length === 0) { + return false; + } + return true; + } + + /** + * Gets a `DataSnapshot` for the location at the specified relative path. + * + * The relative path can either be a simple child name (for example, "ada") or + * a deeper slash-separated path (for example, "ada/name/first"). + * + * @param path A relative path from this location to the desired child + * location. + * @return The specified child location. + */ + child(childPath: string): DataSnapshot { + if (!childPath) { + return this; + } + return this._dup(childPath); + } + + /** + * Enumerates the `DataSnapshot`s of the children items. + * + * Because of the way JavaScript objects work, the ordering of data in the + * JavaScript object returned by `val()` is not guaranteed to match the ordering + * on the server nor the ordering of `child_added` events. That is where + * `forEach()` comes in handy. It guarantees the children of a `DataSnapshot` + * can be iterated in their query order. + * + * If no explicit `orderBy*()` method is used, results are returned + * ordered by key (unless priorities are used, in which case, results are + * returned by priority). + * + * @param action A function that is called for each child `DataSnapshot`. + * The callback can return `true` to cancel further enumeration. + * + * @return `true` if enumeration was canceled due to your callback + * returning `true`. + */ + forEach(action: (a: DataSnapshot) => boolean | void): boolean { + const val = this.val() || {}; + if (typeof val === 'object') { + return Object.keys(val).some((key) => action(this.child(key)) === true); + } + return false; + } + + /** + * Returns `true` if the specified child path has (non-`null`) data. + * + * @param path A relative path to the location of a potential child. + * @return `true` if data exists at the specified child path; otherwise, + * `false`. + */ + hasChild(childPath: string): boolean { + return this.child(childPath).exists(); + } + + /** + * Returns whether or not the `DataSnapshot` has any non-`null` child + * properties. + * + * You can use `hasChildren()` to determine if a `DataSnapshot` has any + * children. If it does, you can enumerate them using `forEach()`. If it + * doesn't, then either this snapshot contains a primitive value (which can be + * retrieved with `val()`) or it is empty (in which case, `val()` returns + * `null`). + * + * @return `true` if this snapshot has any children; else `false`. + */ + hasChildren(): boolean { + const val = this.val(); + return ( + val !== null && typeof val === 'object' && Object.keys(val).length > 0 + ); + } + + /** + * Returns the number of child properties of this `DataSnapshot`. + * + * @return Number of child properties of this `DataSnapshot`. + */ + numChildren(): number { + const val = this.val(); + return val !== null && typeof val === 'object' + ? Object.keys(val).length + : 0; + } + + /** + * Returns a JSON-serializable representation of this object. + * + * @return A JSON-serializable representation of this object. + */ + toJSON(): Object { + return this.val(); + } + + /** Recursive function to check if keys are numeric & convert node object to array if they are + * + * @hidden + */ + private _checkAndConvertToArray(node: any): any { + if (node === null || typeof node === 'undefined') { + return null; + } + if (typeof node !== 'object') { + return node; + } + const obj: any = {}; + let numKeys = 0; + let maxKey = 0; + let allIntegerKeys = true; + for (const key in node) { + if (!node.hasOwnProperty(key)) { + continue; + } + const childNode = node[key]; + const v = this._checkAndConvertToArray(childNode); + if (v === null) { + // Empty child node + continue; + } + obj[key] = v; + numKeys++; + const integerRegExp = /^(0|[1-9]\d*)$/; + if (allIntegerKeys && integerRegExp.test(key)) { + maxKey = Math.max(maxKey, Number(key)); + } else { + allIntegerKeys = false; + } + } + + if (numKeys === 0) { + // Empty node + return null; + } + + if (allIntegerKeys && maxKey < 2 * numKeys) { + // convert to array. + const array: any = []; + for (const key of Object.keys(obj)) { + array[key] = obj[key]; + } + + return array; + } + return obj; + } + + /** @hidden */ + private _dup(childPath?: string): DataSnapshot { + const dup = new DataSnapshot( + this._data, + undefined, + this.app, + this.instance + ); + [dup._path, dup._childPath] = [this._path, this._childPath]; + + if (childPath) { + dup._childPath = joinPath(dup._childPath, childPath); + } + + return dup; + } + + /** @hidden */ + private _fullPath(): string { + return (this._path || '') + '/' + (this._childPath || ''); + } +} diff --git a/src/providers/database.ts b/src/providers/database.ts index e5f4d8ed3..5b7dc59f5 100644 --- a/src/providers/database.ts +++ b/src/providers/database.ts @@ -20,8 +20,6 @@ // 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 { apps } from '../apps'; import { Change, @@ -30,11 +28,14 @@ import { EventContext, makeCloudFunction, } from '../cloud-functions'; +import { DataSnapshot } from '../common/providers/database'; import { firebaseConfig } from '../config'; import { DeploymentOptions } from '../function-configuration'; -import { joinPath, normalizePath, pathParts } from '../utilities/path'; +import { normalizePath } from '../utilities/path'; import { applyChange } from '../utils'; +export { DataSnapshot }; + /** @hidden */ export const provider = 'google.firebase.database'; /** @hidden */ @@ -345,292 +346,3 @@ export function extractInstanceAndPath( return [dbInstance, path]; } } - -/** - * Interface representing a Firebase Realtime Database data snapshot. - */ -export class DataSnapshot { - public instance: string; - - /** @hidden */ - private _ref: firebase.database.Reference; - - /** @hidden */ - private _path: string; - - /** @hidden */ - private _data: any; - - /** @hidden */ - private _childPath: string; - - constructor( - data: any, - path?: string, // path will be undefined for the database root - private app?: firebase.app.App, - instance?: string - ) { - if (app?.options?.databaseURL?.startsWith('http:')) { - // In this case we're dealing with an emulator - this.instance = app.options.databaseURL; - } else if (instance) { - // SDK always supplies instance, but user's unit tests may not - this.instance = instance; - } else if (app) { - this.instance = app.options.databaseURL; - } else if (process.env.GCLOUD_PROJECT) { - this.instance = - 'https://' + process.env.GCLOUD_PROJECT + '.firebaseio.com'; - } - - this._path = path; - this._data = data; - } - - /** - * Returns a [`Reference`](/docs/reference/admin/node/admin.database.Reference) - * to the Database location where the triggering write occurred. Has - * full read and write access. - */ - get ref(): firebase.database.Reference { - if (!this.app) { - // may be unpopulated in user's unit tests - throw new Error( - 'Please supply a Firebase app in the constructor for DataSnapshot' + - ' in order to use the .ref method.' - ); - } - if (!this._ref) { - this._ref = this.app.database(this.instance).ref(this._fullPath()); - } - return this._ref; - } - - /** - * The key (last part of the path) of the location of this `DataSnapshot`. - * - * The last token in a Database location is considered its key. For example, - * "ada" is the key for the `/users/ada/` node. Accessing the key on any - * `DataSnapshot` will return the key for the location that generated it. - * However, accessing the key on the root URL of a Database will return `null`. - */ - get key(): string { - const last = _.last(pathParts(this._fullPath())); - return !last || last === '' ? null : last; - } - - /** - * Extracts a JavaScript value from a `DataSnapshot`. - * - * Depending on the data in a `DataSnapshot`, the `val()` method may return a - * scalar type (string, number, or boolean), an array, or an object. It may also - * return `null`, indicating that the `DataSnapshot` is empty (contains no - * data). - * - * @return The DataSnapshot's contents as a JavaScript value (Object, - * Array, string, number, boolean, or `null`). - */ - val(): any { - const parts = pathParts(this._childPath); - const source = this._data; - const node = _.cloneDeep( - parts.length ? _.get(source, parts, null) : source - ); - return this._checkAndConvertToArray(node); - } - - /** - * Exports the entire contents of the `DataSnapshot` as a JavaScript object. - * - * The `exportVal()` method is similar to `val()`, except priority information - * is included (if available), making it suitable for backing up your data. - * - * @return The contents of the `DataSnapshot` as a JavaScript value - * (Object, Array, string, number, boolean, or `null`). - */ - exportVal(): any { - return this.val(); - } - - /** - * Gets the priority value of the data in this `DataSnapshot`. - * - * As an alternative to using priority, applications can order collections by - * ordinary properties. See [Sorting and filtering - * data](/docs/database/web/lists-of-data#sorting_and_filtering_data). - * - * @return The priority value of the data. - */ - getPriority(): string | number | null { - return 0; - } - - /** - * Returns `true` if this `DataSnapshot` contains any data. It is slightly more - * efficient than using `snapshot.val() !== null`. - * - * @return `true` if this `DataSnapshot` contains any data; otherwise, `false`. - */ - exists(): boolean { - return !_.isNull(this.val()); - } - - /** - * Gets a `DataSnapshot` for the location at the specified relative path. - * - * The relative path can either be a simple child name (for example, "ada") or - * a deeper slash-separated path (for example, "ada/name/first"). - * - * @param path A relative path from this location to the desired child - * location. - * @return The specified child location. - */ - child(childPath: string): DataSnapshot { - if (!childPath) { - return this; - } - return this._dup(childPath); - } - - /** - * Enumerates the `DataSnapshot`s of the children items. - * - * Because of the way JavaScript objects work, the ordering of data in the - * JavaScript object returned by `val()` is not guaranteed to match the ordering - * on the server nor the ordering of `child_added` events. That is where - * `forEach()` comes in handy. It guarantees the children of a `DataSnapshot` - * will be iterated in their query order. - * - * If no explicit `orderBy*()` method is used, results are returned - * ordered by key (unless priorities are used, in which case, results are - * returned by priority). - * - * @param action A function that will be called for each child `DataSnapshot`. - * The callback can return `true` to cancel further enumeration. - * - * @return `true` if enumeration was canceled due to your callback - * returning `true`. - */ - forEach(action: (a: DataSnapshot) => boolean | void): boolean { - const val = this.val(); - if (_.isPlainObject(val)) { - return _.some( - val, - (value, key: string) => action(this.child(key)) === true - ); - } - return false; - } - - /** - * Returns `true` if the specified child path has (non-`null`) data. - * - * @param path A relative path to the location of a potential child. - * @return `true` if data exists at the specified child path; otherwise, - * `false`. - */ - hasChild(childPath: string): boolean { - return this.child(childPath).exists(); - } - - /** - * Returns whether or not the `DataSnapshot` has any non-`null` child - * properties. - * - * You can use `hasChildren()` to determine if a `DataSnapshot` has any - * children. If it does, you can enumerate them using `forEach()`. If it - * doesn't, then either this snapshot contains a primitive value (which can be - * retrieved with `val()`) or it is empty (in which case, `val()` will return - * `null`). - * - * @return `true` if this snapshot has any children; else `false`. - */ - hasChildren(): boolean { - const val = this.val(); - return _.isPlainObject(val) && _.keys(val).length > 0; - } - - /** - * Returns the number of child properties of this `DataSnapshot`. - * - * @return Number of child properties of this `DataSnapshot`. - */ - numChildren(): number { - const val = this.val(); - return _.isPlainObject(val) ? Object.keys(val).length : 0; - } - - /** - * Returns a JSON-serializable representation of this object. - * - * @return A JSON-serializable representation of this object. - */ - toJSON(): Object { - return this.val(); - } - - /** Recursive function to check if keys are numeric & convert node object to array if they are - * - * @hidden - */ - private _checkAndConvertToArray(node: any): any { - if (node === null || typeof node === 'undefined') { - return null; - } - if (typeof node !== 'object') { - return node; - } - const obj: any = {}; - let numKeys = 0; - let maxKey = 0; - let allIntegerKeys = true; - for (const key in node) { - if (!node.hasOwnProperty(key)) { - continue; - } - const childNode = node[key]; - obj[key] = this._checkAndConvertToArray(childNode); - numKeys++; - const integerRegExp = /^(0|[1-9]\d*)$/; - if (allIntegerKeys && integerRegExp.test(key)) { - maxKey = Math.max(maxKey, Number(key)); - } else { - allIntegerKeys = false; - } - } - - if (allIntegerKeys && maxKey < 2 * numKeys) { - // convert to array. - const array: any = []; - _.forOwn(obj, (val, key) => { - array[key] = val; - }); - - return array; - } - return obj; - } - - /** @hidden */ - private _dup(childPath?: string): DataSnapshot { - const dup = new DataSnapshot( - this._data, - undefined, - this.app, - this.instance - ); - [dup._path, dup._childPath] = [this._path, this._childPath]; - - if (childPath) { - dup._childPath = joinPath(dup._childPath, childPath); - } - - return dup; - } - - /** @hidden */ - private _fullPath(): string { - const out = (this._path || '') + '/' + (this._childPath || ''); - return out; - } -} diff --git a/src/utilities/path-pattern.ts b/src/utilities/path-pattern.ts new file mode 100644 index 000000000..dadc96bb1 --- /dev/null +++ b/src/utilities/path-pattern.ts @@ -0,0 +1,173 @@ +// 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 { pathParts } from './path'; + +/** https://cloud.google.com/eventarc/docs/path-patterns */ + +/** @hidden */ +const WILDCARD_CAPTURE_REGEX = new RegExp('{[^/{}]+}', 'g'); + +/** @internal */ +export function trimParam(param: string) { + const paramNoBraces = param.slice(1, -1); + if (paramNoBraces.includes('=')) { + return paramNoBraces.slice(0, paramNoBraces.indexOf('=')); + } + return paramNoBraces; +} + +/** @hidden */ +type SegmentName = 'segment' | 'single-capture' | 'multi-capture'; + +/** @hidden */ +interface PathSegment { + readonly name: SegmentName; + readonly value: string; + readonly trimmed: string; + isSingleSegmentWildcard(): boolean; + isMultiSegmentWildcard(): boolean; +} + +/** @hidden */ +class Segment implements PathSegment { + readonly name = 'segment'; + readonly trimmed: string; + constructor(readonly value: string) { + this.trimmed = value; + } + isSingleSegmentWildcard(): boolean { + return this.value.includes('*') && !this.isMultiSegmentWildcard(); + } + isMultiSegmentWildcard(): boolean { + return this.value.includes('**'); + } +} + +/** @hidden */ +class SingleCaptureSegment implements PathSegment { + readonly name = 'single-capture'; + readonly trimmed: string; + constructor(readonly value: string) { + this.trimmed = trimParam(value); + } + isSingleSegmentWildcard(): boolean { + return true; + } + isMultiSegmentWildcard(): boolean { + return false; + } +} + +/** @hidden */ +class MultiCaptureSegment implements PathSegment { + readonly name = 'multi-capture'; + readonly trimmed: string; + constructor(readonly value: string) { + this.trimmed = trimParam(value); + } + isSingleSegmentWildcard(): boolean { + return false; + } + isMultiSegmentWildcard(): boolean { + return true; + } +} + +/** + * Implements Eventarc's path pattern from the spec https://cloud.google.com/eventarc/docs/path-patterns + * @internal + */ +export class PathPattern { + /** @throws on validation error */ + static compile(rawPath: string) {} + private segments: PathSegment[]; + + constructor(private raw: string) { + this.segments = []; + this.initPathSegments(raw); + } + + getValue(): string { + return this.raw; + } + + // If false, we don't need to use pathPattern as our eventarc match type. + hasWildcards(): boolean { + return this.segments.some( + (segment) => + segment.isSingleSegmentWildcard() || segment.isMultiSegmentWildcard() + ); + } + + hasCaptures(): boolean { + return this.segments.some( + (segment) => + segment.name == 'single-capture' || segment.name === 'multi-capture' + ); + } + + extractMatches(path: string): Record { + const matches: Record = {}; + if (!this.hasCaptures()) { + return matches; + } + const pathSegments = pathParts(path); + let pathNdx = 0; + + for ( + let segmentNdx = 0; + segmentNdx < this.segments.length && pathNdx < pathSegments.length; + segmentNdx++ + ) { + const segment = this.segments[segmentNdx]; + const remainingSegments = this.segments.length - 1 - segmentNdx; + const nextPathNdx = pathSegments.length - remainingSegments; + if (segment.name === 'single-capture') { + matches[segment.trimmed] = pathSegments[pathNdx]; + } else if (segment.name === 'multi-capture') { + matches[segment.trimmed] = pathSegments + .slice(pathNdx, nextPathNdx) + .join('/'); + } + pathNdx = segment.isMultiSegmentWildcard() ? nextPathNdx : pathNdx + 1; + } + + return matches; + } + + private initPathSegments(raw: string) { + const parts = pathParts(raw); + for (const part of parts) { + let segment: PathSegment; + const capture = part.match(WILDCARD_CAPTURE_REGEX); + if (capture && capture.length === 1) { + segment = part.includes('**') + ? new MultiCaptureSegment(part) + : new SingleCaptureSegment(part); + } else { + segment = new Segment(part); + } + this.segments.push(segment); + } + } +} diff --git a/src/v2/index.ts b/src/v2/index.ts index d1cf4836a..d2161ab71 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -30,6 +30,7 @@ import * as logger from '../logger'; import * as alerts from './providers/alerts'; +import * as database from './providers/database'; import * as eventarc from './providers/eventarc'; import * as https from './providers/https'; import * as identity from './providers/identity'; @@ -37,7 +38,17 @@ import * as pubsub from './providers/pubsub'; import * as storage from './providers/storage'; import * as tasks from './providers/tasks'; -export { alerts, storage, https, identity, pubsub, logger, tasks, eventarc }; +export { + alerts, + database, + storage, + https, + identity, + pubsub, + logger, + tasks, + eventarc, +}; export { setGlobalOptions, diff --git a/src/v2/providers/database.ts b/src/v2/providers/database.ts new file mode 100644 index 000000000..b0a38c313 --- /dev/null +++ b/src/v2/providers/database.ts @@ -0,0 +1,409 @@ +// 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 { apps } from '../../apps'; +import { Change } from '../../cloud-functions'; +import { DataSnapshot } from '../../common/providers/database'; +import { ManifestEndpoint } from '../../runtime/manifest'; +import { normalizePath } from '../../utilities/path'; +import { PathPattern } from '../../utilities/path-pattern'; +import { applyChange } from '../../utils'; +import { CloudEvent, CloudFunction } from '../core'; +import * as options from '../options'; + +export { DataSnapshot }; + +/** @internal */ +export const writtenEventType = 'google.firebase.database.ref.v1.written'; + +/** @internal */ +export const createdEventType = 'google.firebase.database.ref.v1.created'; + +/** @internal */ +export const updatedEventType = 'google.firebase.database.ref.v1.updated'; + +/** @internal */ +export const deletedEventType = 'google.firebase.database.ref.v1.deleted'; + +/** @internal */ +export interface RawRTDBCloudEventData { + ['@type']: 'type.googleapis.com/google.events.firebase.database.v1.ReferenceEventData'; + data: any; + delta: any; +} + +/** @internal */ +export interface RawRTDBCloudEvent extends CloudEvent { + firebasedatabasehost: string; + instance: string; + ref: string; + location: string; +} + +/** A CloudEvent that contains a DataSnapshot or a Change */ +export interface DatabaseEvent extends CloudEvent { + /** The domain of the database instance */ + firebaseDatabaseHost: string; + /** The instance ID portion of the fully qualified resource name */ + instance: string; + /** The database reference path */ + ref: string; + /** The location of the database */ + location: string; + /** + * An object containing the values of the path patterns. + * Only named capture groups will be populated - {key}, {key=*}, {key=**} + */ + params: Record; +} + +/** ReferenceOptions extend EventHandlerOptions with provided ref and optional instance */ +export interface ReferenceOptions extends options.EventHandlerOptions { + /** + * Specify the handler to trigger on a database reference(s). + * This value can either be a single reference or a pattern. + * Examples: '/foo/bar', '/foo/{bar}' + */ + ref: string; + /** + * Specify the handler to trigger on a database instance(s). + * If present, this value can either be a single instance or a pattern. + * Examples: 'my-instance-1', '{instance}' + */ + instance?: string; +} + +/** + * Event handler which triggers when data is created, updated, or deleted in Realtime Database. + * + * @param reference - The database reference path to trigger on. + * @param handler - Event handler which is run every time a Realtime Database create, update, or delete occurs. + */ +export function onRefWritten( + reference: string, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>>; + +/** + * Event handler which triggers when data is created, updated, or deleted in Realtime Database. + * + * @param opts - Options that can be set on an individual event-handling function. + * @param handler - Event handler which is run every time a Realtime Database create, update, or delete occurs. + */ +export function onRefWritten( + opts: ReferenceOptions, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>>; + +/** + * Event handler which triggers when data is created, updated, or deleted in Realtime Database. + * + * @param referenceOrOpts - Options or a string reference. + * @param handler - Event handler which is run every time a Realtime Database create, update, or delete occurs. + */ +export function onRefWritten( + referenceOrOpts: string | ReferenceOptions, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>> { + return onChangedOperation(writtenEventType, referenceOrOpts, handler); +} + +/** + * Event handler which triggers when data is created in Realtime Database. + * + * @param reference - The database reference path to trigger on. + * @param handler - Event handler which is run every time a Realtime Database create occurs. + */ +export function onRefCreated( + reference: string, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction>; + +/** + * Event handler which triggers when data is created in Realtime Database. + * + * @param opts - Options that can be set on an individual event-handling function. + * @param handler - Event handler which is run every time a Realtime Database create occurs. + */ +export function onRefCreated( + opts: ReferenceOptions, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction>; + +/** + * Event handler which triggers when data is created in Realtime Database. + * + * @param referenceOrOpts - Options or a string reference. + * @param handler - Event handler which is run every time a Realtime Database create occurs. + */ +export function onRefCreated( + referenceOrOpts: string | ReferenceOptions, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction> { + return onOperation(createdEventType, referenceOrOpts, handler); +} + +/** + * Event handler which triggers when data is updated in Realtime Database. + * + * @param reference - The database reference path to trigger on. + * @param handler - Event handler which is run every time a Realtime Database update occurs. + */ +export function onRefUpdated( + reference: string, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>>; + +/** + * Event handler which triggers when data is updated in Realtime Database. + * + * @param opts - Options that can be set on an individual event-handling function. + * @param handler - Event handler which is run every time a Realtime Database update occurs. + */ +export function onRefUpdated( + opts: ReferenceOptions, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>>; + +/** + * Event handler which triggers when data is updated in Realtime Database. + * + * @param referenceOrOpts - Options or a string reference. + * @param handler - Event handler which is run every time a Realtime Database update occurs. + */ +export function onRefUpdated( + referenceOrOpts: string | ReferenceOptions, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>> { + return onChangedOperation(updatedEventType, referenceOrOpts, handler); +} + +/** + * Event handler which triggers when data is deleted in Realtime Database. + * + * @param reference - The database reference path to trigger on. + * @param handler - Event handler which is run every time a Realtime Database deletion occurs. + */ +export function onRefDeleted( + reference: string, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction>; + +/** + * Event handler which triggers when data is deleted in Realtime Database. + * + * @param opts - Options that can be set on an individual event-handling function. + * @param handler - Event handler which is run every time a Realtime Database deletion occurs. + */ +export function onRefDeleted( + opts: ReferenceOptions, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction>; + +/** + * Event handler which triggers when data is deleted in Realtime Database. + * + * @param referenceOrOpts - Options or a string reference. + * @param handler - Event handler which is run every time a Realtime Database deletion occurs. + */ +export function onRefDeleted( + referenceOrOpts: string | ReferenceOptions, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction> { + // TODO - need to use event.data.delta + return onOperation(deletedEventType, referenceOrOpts, handler); +} + +/** @internal */ +export function getOpts(referenceOrOpts: string | ReferenceOptions) { + let path: string, instance: string, opts: options.EventHandlerOptions; + if (typeof referenceOrOpts === 'string') { + path = normalizePath(referenceOrOpts); + instance = '*'; + opts = {}; + } else { + path = normalizePath(referenceOrOpts.ref); + instance = referenceOrOpts.instance || '*'; + opts = { ...referenceOrOpts }; + delete (opts as any).ref; + delete (opts as any).instance; + } + + return { + path, + instance, + opts, + }; +} + +/** @internal */ +export function makeParams( + event: RawRTDBCloudEvent, + path: PathPattern, + instance: PathPattern +) { + return { + ...path.extractMatches(event.ref), + ...instance.extractMatches(event.instance), + }; +} + +/** @hidden */ +function makeDatabaseEvent( + event: RawRTDBCloudEvent, + data: any, + instance: string, + params: Record +): DatabaseEvent { + const snapshot = new DataSnapshot(data, event.ref, apps().admin, instance); + const databaseEvent: DatabaseEvent = { + ...event, + firebaseDatabaseHost: event.firebasedatabasehost, + data: snapshot, + params, + }; + delete (databaseEvent as any).firebasedatabasehost; + return databaseEvent; +} + +/** @hidden */ +function makeChangedDatabaseEvent( + event: RawRTDBCloudEvent, + instance: string, + params: Record +): DatabaseEvent> { + const before = new DataSnapshot( + event.data.data, + event.ref, + apps().admin, + instance + ); + const after = new DataSnapshot( + applyChange(event.data.data, event.data.delta), + event.ref, + apps().admin, + instance + ); + const databaseEvent: DatabaseEvent> = { + ...event, + firebaseDatabaseHost: event.firebasedatabasehost, + data: { + before, + after, + }, + params, + }; + delete (databaseEvent as any).firebasedatabasehost; + return databaseEvent; +} + +/** @internal */ +export function makeEndpoint( + eventType: string, + opts: options.EventHandlerOptions, + path: PathPattern, + instance: PathPattern +): ManifestEndpoint { + const baseOpts = options.optionsToEndpoint(options.getGlobalOptions()); + const specificOpts = options.optionsToEndpoint(opts); + + const eventFilters: Record = {}; + const eventFilterPathPatterns: Record = { + // Note: Eventarc always treats ref as a path pattern + ref: path.getValue(), + }; + instance.hasWildcards() + ? (eventFilterPathPatterns.instance = instance.getValue()) + : (eventFilters.instance = instance.getValue()); + + return { + platform: 'gcfv2', + ...baseOpts, + ...specificOpts, + labels: { + ...baseOpts?.labels, + ...specificOpts?.labels, + }, + eventTrigger: { + eventType, + eventFilters, + eventFilterPathPatterns, + retry: false, + }, + }; +} + +/** @internal */ +export function onChangedOperation( + eventType: string, + referenceOrOpts: string | ReferenceOptions, + handler: (event: DatabaseEvent>) => any | Promise +): CloudFunction>> { + const { path, instance, opts } = getOpts(referenceOrOpts); + + const pathPattern = new PathPattern(path); + const instancePattern = new PathPattern(instance); + + // wrap the handler + const func = (raw: CloudEvent) => { + const event = raw as RawRTDBCloudEvent; + const instanceUrl = `https://${event.instance}.${event.firebasedatabasehost}`; + const params = makeParams(event, pathPattern, instancePattern); + const databaseEvent = makeChangedDatabaseEvent(event, instanceUrl, params); + return handler(databaseEvent); + }; + + func.run = handler; + + func.__endpoint = makeEndpoint(eventType, opts, pathPattern, instancePattern); + + return func; +} + +/** @internal */ +export function onOperation( + eventType: string, + referenceOrOpts: string | ReferenceOptions, + handler: (event: DatabaseEvent) => any | Promise +): CloudFunction> { + const { path, instance, opts } = getOpts(referenceOrOpts); + + const pathPattern = new PathPattern(path); + const instancePattern = new PathPattern(instance); + + // wrap the handler + const func = (raw: CloudEvent) => { + const event = raw as RawRTDBCloudEvent; + const instanceUrl = `https://${event.instance}.${event.firebasedatabasehost}`; + const params = makeParams(event, pathPattern, instancePattern); + const data = + eventType === deletedEventType ? event.data.data : event.data.delta; + const databaseEvent = makeDatabaseEvent(event, data, instanceUrl, params); + return handler(databaseEvent); + }; + + func.run = handler; + + func.__endpoint = makeEndpoint(eventType, opts, pathPattern, instancePattern); + + return func; +} diff --git a/v2/database.js b/v2/database.js new file mode 100644 index 000000000..c822b56f1 --- /dev/null +++ b/v2/database.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 \ No newline at end of file