"use strict"; // 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. Object.defineProperty(exports, "__esModule", { value: true }); const _ = require("lodash"); const apps_1 = require("../apps"); const cloud_functions_1 = require("../cloud-functions"); const utils_1 = require("../utils"); const config_1 = require("../config"); /** @internal */ exports.provider = 'google.firebase.database'; /** @internal */ exports.service = 'firebaseio.com'; // NOTE(inlined): Should we relax this a bit to allow staging or alternate implementations of our API? const databaseURLRegex = new RegExp('https://([^.]+).firebaseio.com'); /** * Selects a database instance that will trigger the function. * If omitted, will pick the default database for your project. * @param instance The Realtime Database instance to use. */ function instance(instance) { return _instanceWithOpts(instance, {}); } exports.instance = instance; /** * Select Firebase Realtime Database Reference to listen to. * * This method behaves very similarly to the method of the same name in the * client and Admin Firebase SDKs. Any change to the Database that affects the * data at or below the provided `path` will fire an event in Cloud Functions. * * There are three important differences between listening to a Realtime * Database event in Cloud Functions and using the Realtime Database in the * client and Admin SDKs: * 1. Cloud Functions allows wildcards in the `path` name. Any `path` component * in curly brackets (`{}`) is a wildcard that matches all strings. The value * that matched a certain invocation of a Cloud Function is returned as part * of the `context.params` object. For example, `ref("messages/{messageId}")` * matches changes at `/messages/message1` or `/messages/message2`, resulting * in `context.params.messageId` being set to `"message1"` or `"message2"`, * respectively. * 2. Cloud Functions do not fire an event for data that already existed before * the Cloud Function was deployed. * 3. Cloud Function events have access to more information, including information * about the user who triggered the Cloud Function. * @param ref Path of the database to listen to. */ function ref(path) { return _refWithOpts(path, {}); } exports.ref = ref; /** @internal */ function _instanceWithOpts(instance, opts) { return new InstanceBuilder(instance, opts); } exports._instanceWithOpts = _instanceWithOpts; class InstanceBuilder { /* @internal */ constructor(instance, opts) { this.instance = instance; this.opts = opts; } ref(path) { const normalized = utils_1.normalizePath(path); return new RefBuilder(apps_1.apps(), () => `projects/_/instances/${this.instance}/refs/${normalized}`, this.opts); } } exports.InstanceBuilder = InstanceBuilder; /** @internal */ function _refWithOpts(path, opts) { const resourceGetter = () => { const normalized = utils_1.normalizePath(path); const databaseURL = config_1.firebaseConfig().databaseURL; if (!databaseURL) { throw new Error('Missing expected firebase config value databaseURL, ' + 'config is actually' + JSON.stringify(config_1.firebaseConfig()) + '\n If you are unit testing, please set process.env.FIREBASE_CONFIG'); } const match = databaseURL.match(databaseURLRegex); if (!match) { throw new Error('Invalid value for config firebase.databaseURL: ' + databaseURL); } const subdomain = match[1]; return `projects/_/instances/${subdomain}/refs/${normalized}`; }; return new RefBuilder(apps_1.apps(), resourceGetter, opts); } exports._refWithOpts = _refWithOpts; /** Builder used to create Cloud Functions for Firebase Realtime Database References. */ class RefBuilder { /** @internal */ constructor(apps, triggerResource, opts) { this.apps = apps; this.triggerResource = triggerResource; this.opts = opts; this.changeConstructor = (raw) => { let [dbInstance, path] = resourceToInstanceAndPath(raw.context.resource.name); let before = new DataSnapshot(raw.data.data, path, this.apps.admin, dbInstance); let after = new DataSnapshot(utils_1.applyChange(raw.data.data, raw.data.delta), path, this.apps.admin, dbInstance); return { before: before, after: after, }; }; } /** Respond to any write that affects a ref. */ onWrite(handler) { return this.onOperation(handler, 'ref.write', this.changeConstructor); } /** Respond to update on a ref. */ onUpdate(handler) { return this.onOperation(handler, 'ref.update', this.changeConstructor); } /** Respond to new data on a ref. */ onCreate(handler) { let dataConstructor = (raw) => { let [dbInstance, path] = resourceToInstanceAndPath(raw.context.resource.name); return new DataSnapshot(raw.data.delta, path, this.apps.admin, dbInstance); }; return this.onOperation(handler, 'ref.create', dataConstructor); } /** Respond to all data being deleted from a ref. */ onDelete(handler) { let dataConstructor = (raw) => { let [dbInstance, path] = resourceToInstanceAndPath(raw.context.resource.name); return new DataSnapshot(raw.data.data, path, this.apps.admin, dbInstance); }; return this.onOperation(handler, 'ref.delete', dataConstructor); } onOperation(handler, eventType, dataConstructor) { return cloud_functions_1.makeCloudFunction({ handler, provider: exports.provider, service: exports.service, eventType, legacyEventType: `providers/${exports.provider}/eventTypes/${eventType}`, triggerResource: this.triggerResource, dataConstructor: dataConstructor, before: event => this.apps.retain(), after: event => this.apps.release(), opts: this.opts, }); } } exports.RefBuilder = RefBuilder; /* Utility function to extract database reference from resource string */ /** @internal */ function resourceToInstanceAndPath(resource) { let resourceRegex = `projects/([^/]+)/instances/([^/]+)/refs(/.+)?`; let match = resource.match(new RegExp(resourceRegex)); if (!match) { throw new Error(`Unexpected resource string for Firebase Realtime Database event: ${resource}. ` + 'Expected string in the format of "projects/_/instances/{firebaseioSubdomain}/refs/{ref=**}"'); } let [, project, dbInstanceName, path] = match; if (project !== '_') { throw new Error(`Expect project to be '_' in a Firebase Realtime Database event`); } let dbInstance = 'https://' + dbInstanceName + '.firebaseio.com'; return [dbInstance, path]; } exports.resourceToInstanceAndPath = resourceToInstanceAndPath; class DataSnapshot { constructor(data, path, // path will be undefined for the database root app, instance) { this.app = app; 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; } /** Ref returns a reference to the database with full admin access. */ get ref() { 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; } get key() { let last = _.last(utils_1.pathParts(this._fullPath())); return !last || last === '' ? null : last; } val() { let parts = utils_1.pathParts(this._childPath); let source = this._data; let node = _.cloneDeep(parts.length ? _.get(source, parts, null) : source); return this._checkAndConvertToArray(node); } // TODO(inlined): figure out what to do here exportVal() { return this.val(); } // TODO(inlined): figure out what to do here getPriority() { return 0; } exists() { return !_.isNull(this.val()); } child(childPath) { if (!childPath) { return this; } return this._dup(childPath); } forEach(action) { let val = this.val(); if (_.isPlainObject(val)) { return _.some(val, (value, key) => action(this.child(key)) === true); } return false; } hasChild(childPath) { return this.child(childPath).exists(); } hasChildren() { let val = this.val(); return _.isPlainObject(val) && _.keys(val).length > 0; } numChildren() { let val = this.val(); return _.isPlainObject(val) ? Object.keys(val).length : 0; } /** * Prints the value of the snapshot; use '.previous.toJSON()' and '.current.toJSON()' to explicitly see * the previous and current values of the snapshot. */ toJSON() { return this.val(); } /* Recursive function to check if keys are numeric & convert node object to array if they are */ _checkAndConvertToArray(node) { if (node === null || typeof node === 'undefined') { return null; } if (typeof node !== 'object') { return node; } let obj = {}; let numKeys = 0; let maxKey = 0; let allIntegerKeys = true; for (let key in node) { if (!node.hasOwnProperty(key)) { continue; } let 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. let array = []; _.forOwn(obj, (val, key) => { array[key] = val; }); return array; } return obj; } _dup(childPath) { let dup = new DataSnapshot(this._data, undefined, this.app, this.instance); [dup._path, dup._childPath] = [this._path, this._childPath]; if (childPath) { dup._childPath = utils_1.joinPath(dup._childPath, childPath); } return dup; } _fullPath() { let out = (this._path || '') + '/' + (this._childPath || ''); return out; } } exports.DataSnapshot = DataSnapshot;