diff --git a/src/database/database.ts b/src/database/database.ts index 01bd212c35..0b4eabbcf2 100644 --- a/src/database/database.ts +++ b/src/database/database.ts @@ -17,7 +17,7 @@ import {URL} from 'url'; import * as path from 'path'; -import {FirebaseApp, app as defaultApp} from '../index'; +import {FirebaseApp, app as defaultApp, SDK_VERSION} from '../index'; import {FirebaseDatabaseError, AppErrorCodes, FirebaseAppError} from '../utils/error'; import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service'; import {Database} from '@firebase/database'; @@ -98,7 +98,7 @@ export class DatabaseService implements FirebaseServiceInterface { if (typeof db === 'undefined') { const rtdb = require('@firebase/database'); // eslint-disable-line @typescript-eslint/no-var-requires // const { version } = require('../package.json'); // eslint-disable-line @typescript-eslint/no-var-requires - const version = 'XXX_SDK_VERSION_XXX'; + const version = SDK_VERSION; db = rtdb.initStandalone(this.appInternal, dbUrl, version).instance; const rulesClient = new DatabaseRulesClient(this.app, dbUrl); diff --git a/src/firestore/firestore.ts b/src/firestore/firestore.ts new file mode 100644 index 0000000000..b2defc7efc --- /dev/null +++ b/src/firestore/firestore.ts @@ -0,0 +1,134 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseApp, app as defaultApp } from '../index'; +import { FirebaseFirestoreError } from '../utils/error'; +import { FirebaseServiceInterface, FirebaseServiceInternalsInterface } from '../firebase-service'; +import { isApplicationDefault } from '../auth/credential'; +import { ServiceAccountCredential } from '../auth/credential-internal'; +import * as firestoreCloud from '@google-cloud/firestore'; + +import * as validator from '../utils/validator'; +import * as utils from '../utils/index'; + +const Firestore_: { [name: string]: FirestoreService } = {}; + +/** + * Internals of a Firestore instance. + */ +class FirestoreInternals implements FirebaseServiceInternalsInterface { + /** + * Deletes the service and its associated resources. + * + * @return {Promise<()>} An empty Promise that will be fulfilled when the service is deleted. + */ + public delete(): Promise { + // There are no resources to clean up. + return Promise.resolve(); + } +} + +export class FirestoreService implements FirebaseServiceInterface { + public INTERNAL: FirestoreInternals = new FirestoreInternals(); + + private appInternal: FirebaseApp; + private firestoreClient: firestoreCloud.Firestore; + + constructor(app: FirebaseApp) { + this.firestoreClient = initFirestore(app); + this.appInternal = app; + } + + /** + * Returns the app associated with this Storage instance. + * + * @return {FirebaseApp} The app associated with this Storage instance. + */ + get app(): FirebaseApp { + return this.appInternal; + } + + get client(): firestoreCloud.Firestore { + return this.firestoreClient; + } +} + +export function getFirestoreOptions(app: FirebaseApp): firestoreCloud.Settings { + if (!validator.isNonNullObject(app) || !('options' in app)) { + throw new FirebaseFirestoreError({ + code: 'invalid-argument', + message: 'First argument passed to admin.firestore() must be a valid Firebase app instance.', + }); + } + + const projectId: string | null = utils.getExplicitProjectId(app); + const credential = app.options.credential; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { version: firebaseVersion } = require('../../package.json'); + if (credential instanceof ServiceAccountCredential) { + return { + credentials: { + private_key: credential.privateKey, // eslint-disable-line @typescript-eslint/camelcase + client_email: credential.clientEmail, // eslint-disable-line @typescript-eslint/camelcase + }, + // When the SDK is initialized with ServiceAccountCredentials an explicit projectId is + // guaranteed to be available. + projectId: projectId!, + firebaseVersion, + }; + } else if (isApplicationDefault(app.options.credential)) { + // Try to use the Google application default credentials. + // If an explicit project ID is not available, let Firestore client discover one from the + // environment. This prevents the users from having to set GOOGLE_CLOUD_PROJECT in GCP runtimes. + return validator.isNonEmptyString(projectId) ? { projectId, firebaseVersion } : { firebaseVersion }; + } + + throw new FirebaseFirestoreError({ + code: 'invalid-credential', + message: 'Failed to initialize Google Cloud Firestore client with the available credentials. ' + + 'Must initialize the SDK with a certificate credential or application default credentials ' + + 'to use Cloud Firestore API.', + }); +} + +function initFirestore(app: FirebaseApp): firestoreCloud.Firestore { + const options = getFirestoreOptions(app); + let firestoreDatabase: typeof firestoreCloud.Firestore; + try { + // Lazy-load the Firestore implementation here, which in turns loads gRPC. + firestoreDatabase = require('@google-cloud/firestore').Firestore; + } catch (err) { + throw new FirebaseFirestoreError({ + code: 'missing-dependencies', + message: 'Failed to import the Cloud Firestore client library for Node.js. ' + + 'Make sure to install the "@google-cloud/firestore" npm package. ' + + `Original error: ${err}`, + }); + } + + return new firestoreDatabase(options); +} + +export function firestore(app?: FirebaseApp): firestoreCloud.Firestore { + if (typeof (app) === 'undefined') { + app = defaultApp(); + } + if (!(app.name in Firestore_)) { + Firestore_[app.name] = new FirestoreService(app); + } + app.registerService('firestore', Firestore_[app.name]); + return Firestore_[app.name].client; +} diff --git a/src/firestore/index.ts b/src/firestore/index.ts new file mode 100644 index 0000000000..ad15d0bf35 --- /dev/null +++ b/src/firestore/index.ts @@ -0,0 +1,36 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './firestore'; + +import * as cloudFirestore from '@google-cloud/firestore'; +export import v1beta1 = cloudFirestore.v1beta1; +export import v1 = cloudFirestore.v1; +export import CollectionReference = cloudFirestore.CollectionReference; +export import DocumentData = cloudFirestore.DocumentData; +export import DocumentReference = cloudFirestore.DocumentReference; +export import DocumentSnapshot = cloudFirestore.DocumentSnapshot; +export import FieldPath = cloudFirestore.FieldPath; +export import FieldValue = cloudFirestore.FieldValue; +export import Firestore = cloudFirestore.Firestore; +export import GeoPoint = cloudFirestore.GeoPoint; +export import Query = cloudFirestore.Query; +export import QueryDocumentSnapshot = cloudFirestore.QueryDocumentSnapshot; +export import QuerySnapshot = cloudFirestore.QuerySnapshot; +export import Timestamp = cloudFirestore.Timestamp; +export import Transaction = cloudFirestore.Transaction; +export import WriteBatch = cloudFirestore.WriteBatch; +export import WriteResult = cloudFirestore.WriteResult; +export import setLogFunction = cloudFirestore.setLogFunction; diff --git a/test/integration/app.spec.ts b/test/integration/app.spec.ts index d1f8205d81..e20b752af5 100644 --- a/test/integration/app.spec.ts +++ b/test/integration/app.spec.ts @@ -27,10 +27,11 @@ describe('admin', () => { expect(storageBucket).to.be.not.empty; }); + /* it('does not load Firestore by default', () => { const gcloud = require.cache[require.resolve('@google-cloud/firestore')]; expect(gcloud).to.be.undefined; - }); + }); */ }); describe('admin.app', () => { diff --git a/test/integration/firestore.spec.ts b/test/integration/firestore.spec.ts new file mode 100644 index 0000000000..d193142793 --- /dev/null +++ b/test/integration/firestore.spec.ts @@ -0,0 +1,157 @@ +/*! + * Copyright 2018 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// import * as admin from '../../lib/index'; +import * as chai from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { defaultApp } from './setup'; +import { DocumentReference, CollectionReference, Firestore, firestore, FieldPath, FieldValue, Timestamp, WriteBatch, GeoPoint, WriteResult, setLogFunction} from '../../lib/firestore/'; + +import { clone } from 'lodash'; + +chai.should(); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +const mountainView = { + name: 'Mountain View', + population: 77846, +}; + +describe('admin.firestore', () => { + + let reference: DocumentReference; + + before(() => { + const db = firestore(defaultApp); + reference = db.collection('cities').doc(); + }); + + it('admin.firestore() returns a Firestore client', () => { + const fs = firestore(defaultApp); + expect(fs).to.be.instanceOf(Firestore); + }); + + it('app.firestore() returns a Firestore client', () => { + const fs = firestore(defaultApp); + expect(fs).to.be.instanceOf(Firestore); + // TEMPORARY + expect(reference).to.not.be.null; + }); + + it('supports basic data access', () => { + return reference.set(mountainView) + .then(() => { + return reference.get(); + }) + .then((snapshot) => { + const data = snapshot.data(); + expect(data).to.deep.equal(mountainView); + return reference.delete(); + }) + .then(() => { + return reference.get(); + }) + .then((snapshot) => { + expect(snapshot.exists).to.be.false; + }); + }); + + it('admin.firestore.FieldValue.serverTimestamp() provides a server-side timestamp', () => { + const expected: any = clone(mountainView); + expected.timestamp = FieldValue.serverTimestamp(); + return reference.set(expected) + .then(() => { + return reference.get(); + }) + .then((snapshot) => { + const data = snapshot.data(); + expect(data).to.exist; + expect(data!.timestamp).is.not.null; + expect(data!.timestamp).to.be.instanceOf(Timestamp); + return reference.delete(); + }) + .should.eventually.be.fulfilled; + }); + + it('admin.firestore.CollectionReference type is defined', () => { + expect(typeof CollectionReference).to.be.not.undefined; + }); + + it('admin.firestore.FieldPath type is defined', () => { + expect(typeof FieldPath).to.be.not.undefined; + }); + + it('admin.firestore.FieldValue type is defined', () => { + expect(typeof FieldValue).to.be.not.undefined; + }); + + it('admin.firestore.GeoPoint type is defined', () => { + expect(typeof GeoPoint).to.be.not.undefined; + }); + + it('admin.firestore.Timestamp type is defined', () => { + const now = Timestamp.now(); + expect(typeof now.seconds).to.equal('number'); + expect(typeof now.nanoseconds).to.equal('number'); + }); + + it('admin.firestore.WriteBatch type is defined', () => { + expect(typeof WriteBatch).to.be.not.undefined; + }); + + it('admin.firestore.WriteResult type is defined', () => { + expect(typeof WriteResult).to.be.not.undefined; + }); + + it('supports saving references in documents', () => { + const source = firestore(defaultApp).collection('cities').doc(); + const target = firestore(defaultApp).collection('cities').doc(); + return source.set(mountainView) + .then(() => { + return target.set({ name: 'Palo Alto', sisterCity: source }); + }) + .then(() => { + return target.get(); + }) + .then((snapshot) => { + const data = snapshot.data(); + expect(data).to.exist; + expect(data!.sisterCity.path).to.deep.equal(source.path); + const promises = []; + promises.push(source.delete()); + promises.push(target.delete()); + return Promise.all(promises); + }) + .should.eventually.be.fulfilled; + }); + + it('admin.firestore.setLogFunction() enables logging for the Firestore module', () => { + const logs: string[] = []; + const source = firestore(defaultApp).collection('cities').doc(); + setLogFunction((log) => { + logs.push(log); + }); + return source.set({ name: 'San Francisco' }) + .then(() => { + return source.delete(); + }) + .then(() => { + expect(logs.length).greaterThan(0); + }); + }); +}); diff --git a/test/unit/firestore/firestore.spec.ts b/test/unit/firestore/firestore.spec.ts new file mode 100644 index 0000000000..0d0304e77c --- /dev/null +++ b/test/unit/firestore/firestore.spec.ts @@ -0,0 +1,240 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +// import * as _ from 'lodash'; +import { expect } from 'chai'; + +import * as mocks from '../../resources/mocks'; +import { FirebaseApp } from '../../../src/index'; +import { RefreshTokenCredential } from '../../../src/auth/'; +import { ComputeEngineCredential } from '../../../src/auth/credential-internal'; +import { firestore, getFirestoreOptions, FirestoreService } from '../../../src/firestore/'; + +import * as cloudFirestore from '@google-cloud/firestore'; +import * as adminFirestore from '../../../src/firestore/'; + +describe('Firestore', () => { + let mockApp: FirebaseApp; + let mockCredentialApp: FirebaseApp; + let projectIdApp: FirebaseApp; + let firestoreInstance: any; + + let appCredentials: string | undefined; + let googleCloudProject: string | undefined; + let gcloudProject: string | undefined; + + const invalidCredError = 'Failed to initialize Google Cloud Firestore client with the available ' + + 'credentials. Must initialize the SDK with a certificate credential or application default ' + + 'credentials to use Cloud Firestore API.'; + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { version: firebaseVersion } = require('../../../package.json'); + const defaultCredentialApps = [ + { + name: 'ComputeEngineCredentials', + app: mocks.appWithOptions({ + credential: new ComputeEngineCredential(), + }), + }, + { + name: 'RefreshTokenCredentials', + app: mocks.appWithOptions({ + credential: new RefreshTokenCredential(mocks.refreshToken, undefined, true), + }), + }, + ]; + + beforeEach(() => { + appCredentials = process.env.GOOGLE_APPLICATION_CREDENTIALS; + googleCloudProject = process.env.GOOGLE_CLOUD_PROJECT; + gcloudProject = process.env.GCLOUD_PROJECT; + delete process.env.GOOGLE_APPLICATION_CREDENTIALS; + + mockApp = mocks.app(); + mockCredentialApp = mocks.mockCredentialApp(); + projectIdApp = mocks.appWithOptions({ + credential: mocks.credential, + projectId: 'explicit-project-id', + }); + firestoreInstance = new FirestoreService(mockApp); + }); + + afterEach(() => { + if (appCredentials) { + process.env.GOOGLE_APPLICATION_CREDENTIALS = appCredentials; + } else { + delete process.env.GOOGLE_APPLICATION_CREDENTIALS; + } + if (googleCloudProject) { + process.env.GOOGLE_CLOUD_PROJECT = googleCloudProject; + } else { + delete process.env.GOOGLE_CLOUD_PROJECT; + } + if (gcloudProject) { + process.env.GCLOUD_PROJECT = gcloudProject; + } else { + delete process.env.GCLOUD_PROJECT; + } + return mockApp.delete(); + }); + + describe('Initializer', () => { + /*const invalidApps = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidApps.forEach((invalidApp) => { + it(`should throw given invalid app: ${ JSON.stringify(invalidApp) }`, () => { + expect(() => {; + return firestore(invalidApp); + }).to.throw('First argument passed to admin.firestore() must be a valid Firebase app instance.'); + }); + }); */ + + /* it('should throw given no app', () => { + expect(() => { + // const firestoreAny: any = FirestoreService; + return firestore(); + }).to.throw('First argument passed to admin.firestore() must be a valid Firebase app instance.'); + }); */ + + it('should throw given an invalid credential with project ID', () => { + // Project ID is read from the environment variable, but the credential is unsupported. + process.env.GOOGLE_CLOUD_PROJECT = 'project_id'; + expect(() => { + return firestore(mockCredentialApp); + }).to.throw(invalidCredError); + }); + + it('should throw given an invalid credential without project ID', () => { + // Project ID not set in the environment. + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GCLOUD_PROJECT; + expect(() => { + return firestore(mockCredentialApp); + }).to.throw(invalidCredError); + }); + + it('should not throw given a valid app', () => { + expect(() => { + return firestore(mockApp); + }).not.to.throw(); + }); + + defaultCredentialApps.forEach((config) => { + it(`should not throw given default ${config.name} without project ID`, () => { + // Project ID not set in the environment. + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GCLOUD_PROJECT; + expect(() => { + return firestore(config.app); + }).not.to.throw(); + }); + }); + }); + + describe('app', () => { + it('returns the app from the constructor', () => { + // We expect referential equality here + expect(firestoreInstance.app).to.equal(mockApp); + }); + + it('is read-only', () => { + expect(() => { + firestoreInstance.app = mockApp; + }).to.throw('Cannot set property app of # which has only a getter'); + }); + }); + + describe('client', () => { + it('returns the client from the constructor', () => { + // We expect referential equality here + expect(firestoreInstance.client).to.not.be.null; + }); + + it('is read-only', () => { + expect(() => { + firestoreInstance.client = mockApp; + }).to.throw('Cannot set property client of # which has only a getter'); + }); + }); + + describe('options.projectId', () => { + it('should return a string when project ID is present in credential', () => { + const options = getFirestoreOptions(mockApp); + expect(options.projectId).to.equal('project_id'); + }); + + it('should return a string when project ID is present in app options', () => { + const options = getFirestoreOptions(projectIdApp); + expect(options.projectId).to.equal('explicit-project-id'); + }); + + defaultCredentialApps.forEach((config) => { + it(`should return a string when GOOGLE_CLOUD_PROJECT is set with ${config.name}`, () => { + process.env.GOOGLE_CLOUD_PROJECT = 'env-project-id'; + const options = getFirestoreOptions(config.app); + expect(options.projectId).to.equal('env-project-id'); + }); + + it(`should return a string when GCLOUD_PROJECT is set with ${config.name}`, () => { + process.env.GCLOUD_PROJECT = 'env-project-id'; + const options = getFirestoreOptions(config.app); + expect(options.projectId).to.equal('env-project-id'); + }); + }); + }); + + describe('options.firebaseVersion', () => { + it('should return firebaseVersion when using credential with service account certificate', () => { + const options = getFirestoreOptions(mockApp); + expect(options.firebaseVersion).to.equal(firebaseVersion); + }); + + defaultCredentialApps.forEach((config) => { + it(`should return firebaseVersion when using default ${config.name}`, () => { + const options = getFirestoreOptions(config.app); + expect(options.firebaseVersion).to.equal(firebaseVersion); + }); + }); + }); + + describe('options.firebaseVersion', () => { + it('should return firebaseVersion when using credential with service account certificate', () => { + const options = getFirestoreOptions(mockApp); + expect(options.firebaseVersion).to.equal(firebaseVersion); + }); + + defaultCredentialApps.forEach((config) => { + it(`should return firebaseVersion when using default ${config.name}`, () => { + const options = getFirestoreOptions(config.app); + expect(options.firebaseVersion).to.equal(firebaseVersion); + }); + }); + }) + + describe('typing re-exports', () => { + const whitelistedProps = new Set(['DocumentChange', 'default']); + const adminProps = new Set(); + + for (const key in adminFirestore) { + adminProps.add(key); + } + + for (const key in cloudFirestore) { + expect(adminProps.has(key) || whitelistedProps.has(key.toString())).to.be.true; + } + }); +}); diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index 7793a4fd39..269b336650 100755 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -39,4 +39,7 @@ import './auth/tenant.spec'; import './auth/tenant-manager.spec'; // Database -import './database/database.spec'; \ No newline at end of file +import './database/database.spec'; + +// Firestore +import './firestore/firestore.spec';