diff --git a/.gitignore b/.gitignore index 4fcdbc1..7715f92 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,8 @@ build/ # Directory created by dartdoc doc/api/ -*.iml \ No newline at end of file +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index 6231528..f58abbc 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Currently, only supports admin methods for the following firebase services: * authentication * realtime database +* cloud firestore ## Example using service account diff --git a/lib/firebase_admin.dart b/lib/firebase_admin.dart index bf73dd1..d9f17be 100644 --- a/lib/firebase_admin.dart +++ b/lib/firebase_admin.dart @@ -3,5 +3,10 @@ library firebase_admin; export 'src/admin.dart'; export 'src/app.dart' hide AppInternalsExtension; export 'src/auth.dart'; -export 'src/utils/error.dart'; export 'src/credential.dart' hide setApplicationDefaultCredential; +export 'src/firestore/collection.dart'; +export 'src/firestore/document.dart'; +export 'src/firestore/firestore.dart'; +export 'src/firestore/query.dart'; +export 'src/firestore/transaction.dart'; +export 'src/utils/error.dart'; diff --git a/lib/src/app.dart b/lib/src/app.dart index eab5a6a..3e0d597 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -50,6 +50,9 @@ class App { /// Gets [Storage] service for this application. Storage storage() => _getService(() => Storage(this)); + /// Gets [Firestore] service for this application. + Firestore firestore() => _getService(() => Firestore(this)); + /// Renders this app unusable and frees the resources of all associated /// services. Future delete() async { diff --git a/lib/src/firestore/collection.dart b/lib/src/firestore/collection.dart new file mode 100644 index 0000000..7c23c25 --- /dev/null +++ b/lib/src/firestore/collection.dart @@ -0,0 +1,45 @@ +import 'package:firebase_admin/src/firestore/document.dart'; +import 'package:firebase_admin/src/firestore/firestore.dart'; +import 'package:firebase_admin/src/firestore/query.dart'; +import 'package:firebase_admin/src/firestore/utils/auto_id_generator.dart'; +import 'package:firebase_admin/src/firestore/utils/pointer.dart'; + +class CollectionReference extends Query { + final Pointer _pointer; + + CollectionReference({ + required super.firestore, + required super.toFirestore, + required super.fromFirestore, + required super.path, + }) : _pointer = Pointer(path), + super(query: null) { + assert(_pointer.isCollection()); + } + + DocumentReference doc([String? id]) { + return DocumentReference( + firestore: firestore, + toFirestore: toFirestore, + fromFirestore: fromFirestore, + path: _pointer.documentPath(id ?? AutoIdGenerator.autoId()), + ); + } + + Future> add(T data) async { + final document = doc(); + return await document.set(data); + } + + CollectionReference withConverter({ + required FromFirestore fromFirestore, + required ToFirestore toFirestore, + }) { + return CollectionReference( + firestore: firestore, + path: path, + fromFirestore: fromFirestore, + toFirestore: toFirestore, + ); + } +} diff --git a/lib/src/firestore/document.dart b/lib/src/firestore/document.dart new file mode 100644 index 0000000..606eb75 --- /dev/null +++ b/lib/src/firestore/document.dart @@ -0,0 +1,92 @@ +import 'package:firebase_admin/src/firestore/firestore.dart'; +import 'package:firebase_admin/src/firestore/utils/document_snapshot.dart'; +import 'package:firebase_admin/src/firestore/utils/pointer.dart'; +import 'package:firebase_admin/src/firestore/utils/serialization.dart'; +import 'package:googleapis/firestore/v1.dart'; + +class DocumentReference { + final Firestore firestore; + final ToFirestore toFirestore; + final FromFirestore fromFirestore; + final Pointer _pointer; + String get path => _pointer.path; + String get id => _pointer.id; + + DocumentReference({ + required this.firestore, + required this.toFirestore, + required this.fromFirestore, + required String path, + }) : _pointer = Pointer(path) { + assert(_pointer.isDocument()); + } + + Future> set(T data) async { + final result = await firestore.docsApi.createDocument( + Document(fields: serializeData(toFirestore(data))), + firestore.databasePath, + _pointer.parentPath()!, + documentId: _pointer.id, + ); + + return SerializableDocumentSnapshot( + firestore: firestore, + toFirestore: toFirestore, + fromFirestore: fromFirestore, + document: result, + ); + } + + Future> get() async { + final result = await firestore.docsApi.get('${firestore.databasePath}/$path'); + + return SerializableDocumentSnapshot( + firestore: firestore, + toFirestore: toFirestore, + fromFirestore: fromFirestore, + document: result, + ); + } + + Future> update(Map data) async { + final result = await firestore.docsApi.patch( + Document(fields: serializeData(data)), + '${firestore.databasePath}/${_pointer.path}', + ); + + return SerializableDocumentSnapshot( + firestore: firestore, + toFirestore: toFirestore, + fromFirestore: fromFirestore, + document: result, + ); + } + + Future delete() async { + await firestore.docsApi.delete('${firestore.databasePath}/$path'); + } + + DocumentReference withConverter({ + required FromFirestore fromFirestore, + required ToFirestore toFirestore, + }) { + return DocumentReference( + firestore: firestore, + path: path, + fromFirestore: fromFirestore, + toFirestore: toFirestore, + ); + } +} + +abstract class DocumentSnapshot { + final Firestore firestore; + + const DocumentSnapshot({ + required this.firestore, + }); + + DocumentReference get reference; + + T data(); +} diff --git a/lib/src/firestore/firestore.dart b/lib/src/firestore/firestore.dart new file mode 100644 index 0000000..b616fde --- /dev/null +++ b/lib/src/firestore/firestore.dart @@ -0,0 +1,60 @@ +import 'package:firebase_admin/src/app.dart'; +import 'package:firebase_admin/src/app/app_extension.dart'; +import 'package:firebase_admin/src/firestore/collection.dart'; +import 'package:firebase_admin/src/firestore/document.dart'; +import 'package:firebase_admin/src/firestore/transaction.dart'; +import 'package:firebase_admin/src/firestore/utils/serialization.dart'; +import 'package:firebase_admin/src/service.dart'; +import 'package:firebase_admin/src/utils/api_request.dart'; +import 'package:googleapis/firestore/v1.dart'; +import 'package:meta/meta.dart'; + +typedef ToFirestore = Map Function(T value); +typedef FromFirestore = T Function(DocumentSnapshot> snapshot); + +class Firestore implements FirebaseService { + @override + final App app; + + final FirestoreApi _api; + + @internal + ProjectsDatabasesDocumentsResource get docsApi => _api.projects.databases.documents; + + @internal + String get databasePath => 'projects/${app.projectId}/databases/(default)/documents'; + + Firestore(this.app) : _api = FirestoreApi(AuthorizedHttpClient(app)); + + @override + Future delete() async {} + + CollectionReference> collection(String id) { + return CollectionReference( + firestore: this, + fromFirestore: fromFirestore, + toFirestore: toFirestore, + path: id, + ); + } + + DocumentReference> doc(String id) { + return DocumentReference( + firestore: this, + fromFirestore: fromFirestore, + toFirestore: toFirestore, + path: id, + ); + } + + Future runTransaction( + Future Function(Transaction transaction) handler, { + Duration timeout = const Duration(seconds: 30), + }) { + return Transaction.run( + firestore: this, + timeout: timeout, + handler: handler, + ); + } +} diff --git a/lib/src/firestore/query.dart b/lib/src/firestore/query.dart new file mode 100644 index 0000000..ce6d62a --- /dev/null +++ b/lib/src/firestore/query.dart @@ -0,0 +1,218 @@ +import 'package:collection/collection.dart'; +import 'package:googleapis/firestore/v1.dart'; +import 'package:meta/meta.dart'; + +import 'document.dart'; +import 'firestore.dart'; +import 'utils/document_snapshot.dart'; +import 'utils/serialization.dart'; + +class Query { + final Firestore firestore; + final ToFirestore toFirestore; + final FromFirestore fromFirestore; + final String path; + + final StructuredQuery _query; + + Query({ + required this.firestore, + required this.toFirestore, + required this.fromFirestore, + required this.path, + @internal required StructuredQuery? query, + }) : _query = query ?? + StructuredQuery( + from: [CollectionSelector(collectionId: path)], + ); + + Query endAt(Iterable values) { + return _copyWith( + endAt: Cursor( + before: false, + values: values.map(serializeValue).toList(), + ), + ); + } + + Query endBefore(Iterable values) { + return _copyWith( + endAt: Cursor( + before: true, + values: values.map(serializeValue).toList(), + ), + ); + } + + Query limit(int limit) => _copyWith(limit: limit); + + Query orderBy(String field, {bool descending = false}) { + return _copyWith( + orderBy: Order( + direction: descending ? 'DESCENDING' : 'ASCENDING', + field: FieldReference( + fieldPath: field, + ), + ), + ); + } + + Query startAfter(Iterable values) { + return _copyWith( + endAt: Cursor( + before: false, + values: values.map(serializeValue).toList(), + ), + ); + } + + Query startAt(Iterable values) { + return _copyWith( + endAt: Cursor( + before: true, + values: values.map(serializeValue).toList(), + ), + ); + } + + Query where( + String field, { + Object? isEqualTo, + Object? isNotEqualTo, + Object? isLessThan, + Object? isLessThanOrEqualTo, + Object? isGreaterThan, + Object? isGreaterThanOrEqualTo, + Object? arrayContains, + Iterable? arrayContainsAny, + Iterable? whereIn, + Iterable? whereNotIn, + bool? isNull, + }) { + Filter createFieldFilter(String op, Object value) { + return Filter( + fieldFilter: FieldFilter( + field: FieldReference(fieldPath: field), + op: op, + value: serializeValue(value), + ), + ); + } + + final filters = [ + if (isLessThan != null) createFieldFilter('LESS_THAN', isLessThan), + if (isLessThanOrEqualTo != null) createFieldFilter('LESS_THAN_OR_EQUAL', isLessThanOrEqualTo), + if (isGreaterThan != null) createFieldFilter('GREATER_THAN', isGreaterThan), + if (isGreaterThanOrEqualTo != null) + createFieldFilter('GREATER_THAN_OR_EQUAL', isGreaterThanOrEqualTo), + if (isEqualTo != null) createFieldFilter('EQUAL', isEqualTo), + if (isNotEqualTo != null) createFieldFilter('NOT_EQUAL', isNotEqualTo), + if (isLessThan != null) createFieldFilter('ARRAY_CONTAINS', isLessThan), + if (arrayContains != null) createFieldFilter('ARRAY_CONTAINS_ANY', arrayContains), + if (whereIn != null) createFieldFilter('IN', whereIn), + if (arrayContainsAny != null) createFieldFilter('LESS_THAN', arrayContainsAny), + if (whereNotIn != null) createFieldFilter('NOT_IN', whereNotIn), + if (isNull != null) + Filter( + unaryFilter: UnaryFilter( + field: FieldReference(fieldPath: field), + op: isNull ? 'IS_NULL' : 'IS_NOT_NULL', + ), + ) + ]; + + return _copyWith( + where: filters, + ); + } + + Future> get({ + @internal String? transactionId, + }) async { + final result = await firestore.docsApi.runQuery( + RunQueryRequest( + structuredQuery: _query, + transaction: transactionId, + ), + firestore.databasePath, + ); + + return QuerySnapshot( + firestore: firestore, + toFirestore: toFirestore, + fromFirestore: fromFirestore, + docs: result.map((e) => e.document!).toList(), + ); + } + + Query _copyWith({ + Cursor? endAt, + int? limit, + Order? orderBy, + Cursor? startAt, + List? where, + }) { + final prevWhere = _query.where; + final filters = [ + if (prevWhere != null) + if (prevWhere.compositeFilter?.filters != null) + ...prevWhere.compositeFilter!.filters! + else + prevWhere, + ...?where, + ]; + return Query( + firestore: firestore, + toFirestore: toFirestore, + fromFirestore: fromFirestore, + path: path, + query: StructuredQuery( + endAt: endAt ?? _query.endAt, + from: _query.from, // ??? + limit: limit ?? _query.limit, + offset: null, // ??? + orderBy: orderBy != null ? [...?_query.orderBy, orderBy] : _query.orderBy, + select: null, // Returns all document fields. + startAt: startAt ?? _query.startAt, + where: filters.isEmpty + ? null + : (filters.singleOrNull ?? + Filter( + compositeFilter: CompositeFilter( + op: 'AND', + filters: filters, + ), + )), + ), + ); + } +} + +class QuerySnapshot { + final Firestore firestore; + final ToFirestore toFirestore; + final FromFirestore fromFirestore; + + final List _docs; + + @internal + const QuerySnapshot({ + required this.firestore, + required this.toFirestore, + required this.fromFirestore, + required List docs, + }) : _docs = docs; + + List> get docs { + return _docs.map((e) { + return SerializableDocumentSnapshot( + firestore: firestore, + toFirestore: toFirestore, + fromFirestore: fromFirestore, + document: e, + ); + }).toList(); + } + + int get size => _docs.length; +} diff --git a/lib/src/firestore/transaction.dart b/lib/src/firestore/transaction.dart new file mode 100644 index 0000000..52a5812 --- /dev/null +++ b/lib/src/firestore/transaction.dart @@ -0,0 +1,100 @@ +import 'package:firebase_admin/src/firestore/document.dart'; +import 'package:firebase_admin/src/firestore/firestore.dart'; +import 'package:firebase_admin/src/firestore/query.dart'; +import 'package:firebase_admin/src/firestore/utils/document_snapshot.dart'; +import 'package:firebase_admin/src/firestore/utils/serialization.dart'; +import 'package:googleapis/firestore/v1.dart'; +import 'package:meta/meta.dart'; + +class Transaction { + final Firestore firestore; + final String id; + + final List _writes; + + Transaction._({ + required this.firestore, + required this.id, + }) : _writes = []; + + Future> get(DocumentReference ref) async { + final result = await firestore.docsApi.get( + '${firestore.databasePath}/${ref.path}', + transaction: id, + ); + + return SerializableDocumentSnapshot( + firestore: firestore, + toFirestore: ref.toFirestore, + fromFirestore: ref.fromFirestore, + document: result, + ); + } + + Future> getBy(Query query) async { + return query.get(transactionId: id); + } + + void set(DocumentReference ref, T data) { + _writes.add(Write( + currentDocument: Precondition(exists: false), + update: Document( + name: '${firestore.databasePath}/${ref.path}', + fields: serializeData(ref.toFirestore(data)), + ), + )); + } + + void update(DocumentReference ref, Map data) { + _writes.add(Write( + currentDocument: Precondition(exists: true), + update: Document( + name: '${firestore.databasePath}/${ref.path}', + fields: serializeData(data), + ), + )); + } + + void delete(DocumentReference ref) { + _writes.add(Write( + delete: '${firestore.databasePath}/${ref.path}', + )); + } + + @internal + static Future run({ + required Firestore firestore, + Duration timeout = const Duration(seconds: 30), + // int maxAttempts = 5, TODO: Implement it + required Future Function(Transaction transaction) handler, + }) async { + assert(timeout.inMilliseconds > 0, 'Transaction timeout must be more than 0 milliseconds'); + + final beginTransactionRequest = BeginTransactionRequest(); + final transactionResponse = await firestore.docsApi.beginTransaction( + beginTransactionRequest, + firestore.databasePath, + ); + try { + final transaction = Transaction._( + firestore: firestore, + id: transactionResponse.transaction!, + ); + final result = await handler(transaction).timeout(timeout); + + final commitRequest = CommitRequest( + transaction: transactionResponse.transaction, + writes: transaction._writes, + ); + + await firestore.docsApi.commit(commitRequest, firestore.databasePath); + return result; + } catch (_) { + final rollbackRequest = RollbackRequest( + transaction: transactionResponse.transaction, + ); + await firestore.docsApi.rollback(rollbackRequest, firestore.databasePath); + rethrow; + } + } +} diff --git a/lib/src/firestore/utils/auto_id_generator.dart b/lib/src/firestore/utils/auto_id_generator.dart new file mode 100644 index 0000000..c40c850 --- /dev/null +++ b/lib/src/firestore/utils/auto_id_generator.dart @@ -0,0 +1,34 @@ +// ignore_for_file: constant_identifier_names + +import 'dart:math'; + +/// Original File: package:cloud_firestore_platform_interface/lib/src/method_channel/method_channel_collection_reference.dart +/// +/// Utility class for generating Firebase child node keys. +/// +/// Since the Flutter plugin API is asynchronous, there's no way for us +/// to use the native SDK to generate the node key synchronously and we +/// have to do it ourselves if we want to be able to reference the +/// newly-created node synchronously. +/// +/// This code is based largely on the Android implementation and ported to Dart. +class AutoIdGenerator { + static const int _AUTO_ID_LENGTH = 20; + + static const String _AUTO_ID_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + static final Random _random = Random(); + + /// Automatically Generates a random new Id + static String autoId() { + final StringBuffer stringBuffer = StringBuffer(); + const int maxRandom = _AUTO_ID_ALPHABET.length; + + for (int i = 0; i < _AUTO_ID_LENGTH; ++i) { + stringBuffer.write(_AUTO_ID_ALPHABET[_random.nextInt(maxRandom)]); + } + + return stringBuffer.toString(); + } +} diff --git a/lib/src/firestore/utils/document_snapshot.dart b/lib/src/firestore/utils/document_snapshot.dart new file mode 100644 index 0000000..bfae562 --- /dev/null +++ b/lib/src/firestore/utils/document_snapshot.dart @@ -0,0 +1,48 @@ +import 'package:firebase_admin/src/firestore/document.dart'; +import 'package:firebase_admin/src/firestore/firestore.dart'; +import 'package:firebase_admin/src/firestore/utils/serialization.dart'; +import 'package:googleapis/firestore/v1.dart'; + +class SerializableDocumentSnapshot extends DocumentSnapshot { + final Document _document; + final ToFirestore toFirestore; + final FromFirestore fromFirestore; + + const SerializableDocumentSnapshot({ + required super.firestore, + required this.toFirestore, + required this.fromFirestore, + required Document document, + }) : _document = document; + + @override + DocumentReference get reference => DocumentReference( + firestore: firestore, + path: _document.name!, + toFirestore: toFirestore, + fromFirestore: fromFirestore, + ); + + @override + T data() => fromFirestore(_RawDocumentSnapshot(firestore: firestore, document: _document)); +} + +class _RawDocumentSnapshot extends DocumentSnapshot> { + final Document _document; + + const _RawDocumentSnapshot({ + required super.firestore, + required Document document, + }) : _document = document; + + @override + DocumentReference> get reference => DocumentReference( + firestore: firestore, + path: _document.name!, + fromFirestore: fromFirestore, + toFirestore: toFirestore, + ); + + @override + Map data() => deserializeData(firestore, _document.fields!); +} diff --git a/lib/src/firestore/utils/pointer.dart b/lib/src/firestore/utils/pointer.dart new file mode 100644 index 0000000..88cfe42 --- /dev/null +++ b/lib/src/firestore/utils/pointer.dart @@ -0,0 +1,77 @@ +import 'package:meta/meta.dart'; + +/// Original File: cloud_firestore_platform_interface/lib/src/internal/pointer.dart +/// +/// A helper class to handle Firestore paths. +/// +/// This class is used internally to manage paths which point to a collection +/// or document on Firestore. Since paths can be deeply nested, the class helps +/// to reduce code repetition and improve testability. +@immutable +class Pointer { + /// Create instance of [Pointer] + Pointer(String path) + : components = path.split('/').where((element) => element.isNotEmpty).toList(); + + /// The Firestore normalized path of the [Pointer]. + String get path { + return components.join('/'); + } + + /// Pointer components of the path. + /// + /// This is used to determine whether a path is a collection or document. + final List components; + + /// Returns the ID for this pointer. + /// + /// The ID is the last component of a given path. For example, the ID of the + /// document "user/123" is "123". + String get id { + return components.last; + } + + /// Returns whether the given path is a pointer to a Firestore collection. + /// + /// Collections are paths whose components are not dividable by 2, for example + /// "collection/document/sub-collection". + bool isCollection() { + return components.length.isOdd; + } + + /// Returns whether the given path is a pointer to a Firestore document. + /// + /// Documents are paths whose components are dividable by 2, for example + /// "collection/document". + bool isDocument() { + return components.length.isEven; + } + + /// Returns a new collection path from the current document pointer. + String collectionPath(String collectionPath) { + assert(isDocument()); + return '$path/$collectionPath'; + } + + /// Returns a new document path from the current collection pointer. + String documentPath(String documentPath) { + assert(isCollection()); + return '$path/$documentPath'; + } + + /// Returns a path pointing to the parent of the current path. + String? parentPath() { + if (components.length < 2) { + return null; + } + + List parentComponents = List.from(components)..removeLast(); + return parentComponents.join('/'); + } + + @override + bool operator ==(Object other) => other is Pointer && other.path == path; + + @override + int get hashCode => path.hashCode; +} diff --git a/lib/src/firestore/utils/serialization.dart b/lib/src/firestore/utils/serialization.dart new file mode 100644 index 0000000..4886e12 --- /dev/null +++ b/lib/src/firestore/utils/serialization.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:firebase_admin/src/firestore/document.dart'; +import 'package:googleapis/firestore/v1.dart'; +import 'package:maps_toolkit/maps_toolkit.dart' as maps_toolkit; + +import '../firestore.dart'; + +Map fromFirestore(DocumentSnapshot> snapshot) => + snapshot.data(); + +Map toFirestore(Map value) => value; + +Map deserializeData(Firestore firestore, Map fields) { + return fields.map((key, value) => MapEntry(key, deserializeValue(firestore, value))); +} + +Map serializeData(Map data) { + return data.map((key, value) => MapEntry(key, serializeValue(value))); +} + +dynamic deserializeValue(Firestore firestore, Value value) { + if (value.arrayValue != null) { + return value.arrayValue!.values!.map((value) => deserializeValue(firestore, value)).toList(); + } else if (value.booleanValue != null) { + return value.booleanValue!; + } else if (value.bytesValue != null) { + return base64.decode(value.bytesValue!); + } else if (value.doubleValue != null) { + return value.doubleValue!; + } else if (value.geoPointValue != null) { + return maps_toolkit.LatLng(value.geoPointValue!.latitude!, value.geoPointValue!.longitude!); + } else if (value.integerValue != null) { + return int.parse(value.integerValue!); + } else if (value.mapValue != null) { + return deserializeData(firestore, value.mapValue!.fields!); + } else if (value.nullValue != null) { + return null; + } else if (value.referenceValue != null) { + return DocumentReference>( + firestore: firestore, + fromFirestore: fromFirestore, + toFirestore: toFirestore, + path: value.referenceValue!, + ); + } else if (value.stringValue != null) { + return value.stringValue!; + } else if (value.timestampValue != null) { + return DateTime.fromMicrosecondsSinceEpoch(int.parse(value.timestampValue!)); + } +} + +Value serializeValue(dynamic data) { + return Value( + arrayValue: data is List ? ArrayValue(values: data.map(serializeValue).toList()) : null, + booleanValue: data is bool ? data : null, + bytesValue: + data is Uint8List ? base64.encode(data).replaceAll('/', '_').replaceAll('+', '-') : null, + doubleValue: data is double ? data : null, + geoPointValue: data is maps_toolkit.LatLng + ? LatLng(latitude: data.latitude, longitude: data.longitude) + : null, + integerValue: data is int ? '$data' : null, + mapValue: data is Map ? MapValue(fields: serializeData(data)) : null, + nullValue: data == null ? 'nullValue' : null, + referenceValue: data is DocumentReference> ? data.path : null, + stringValue: data is String ? data : null, + timestampValue: data is DateTime ? '${data.microsecondsSinceEpoch}' : null, + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index b268354..2bdaaf3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ version: 0.3.0-dev.4 homepage: https://github.com/appsup-dart/firebase_admin environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.17.0 <3.0.0' dependencies: clock: ^1.1.0 @@ -18,6 +18,7 @@ dependencies: http: ^0.13.0 crypto_keys: ^0.3.0 collection: ^1.15.0 + maps_toolkit: ^2.0.1 gcloud: ^0.8.0 firebaseapis: ^0.1.2 snapshot: ^0.2.5