| // Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| // @dart = 2.9 |
| |
| import 'dart:convert' show jsonDecode, jsonEncode; |
| import 'dart:io' show File, HttpStatus; |
| |
| import 'package:http/http.dart' as http; |
| |
| Future<String> readGcloudAuthToken(String path) async { |
| String token = await File(path).readAsString(); |
| return token.split("\n").first; |
| } |
| |
| /// Helper class to access the Firestore REST API. |
| /// |
| /// This class is not a complete implementation of the Firestore REST protocol |
| /// and is only meant to support the operations required by scripts in |
| /// tools/bots. |
| class FirestoreDatabase { |
| final http.Client _client = http.Client(); |
| final Map<String, String> _headers; |
| final Uri _documentsUrl; |
| final Uri _queryUrl; |
| final Uri _beginTransactionUrl; |
| final Uri _commitUrl; |
| |
| /// The current transaction ID in base64 (or `null`) |
| String _currentTransaction; |
| |
| /// Returns the current transaction escaped to be useable as part of a URI. |
| String get _escapedCurrentTransaction { |
| return Uri.encodeFull(_currentTransaction) |
| // The Firestore API does not accept '+' in URIs |
| .replaceAll("+", "%2B"); |
| } |
| |
| FirestoreDatabase._(this._headers, this._documentsUrl, this._queryUrl, |
| this._beginTransactionUrl, this._commitUrl); |
| |
| factory FirestoreDatabase(String project, String authToken) { |
| var databasePath = 'projects/$project/databases/(default)'; |
| var databaseUrl = _apiUrl.resolve('$databasePath/'); |
| var documentsUrl = databaseUrl.resolve('documents/'); |
| var queryUrl = _apiUrl.resolve('$databasePath/documents:runQuery'); |
| var beginTransactionUrl = |
| _apiUrl.resolve('$databasePath/documents:beginTransaction'); |
| var commitUrl = _apiUrl.resolve('$databasePath/documents:commit'); |
| var headers = { |
| 'Authorization': 'Bearer $authToken', |
| 'Accept': 'application/json', |
| 'Content-Type': 'application/json' |
| }; |
| return FirestoreDatabase._( |
| headers, documentsUrl, queryUrl, beginTransactionUrl, commitUrl); |
| } |
| |
| static final _apiUrl = Uri.https('firestore.googleapis.com', 'v1/'); |
| |
| Future<List /*!*/ > runQuery(Query query) async { |
| var body = jsonEncode(query.data); |
| var response = await _client.post(_queryUrl, headers: _headers, body: body); |
| if (response.statusCode == HttpStatus.ok) { |
| return jsonDecode(response.body); |
| } else { |
| throw _error(response); |
| } |
| } |
| |
| Future<Map> getDocument(String collectionName, String documentName) async { |
| var url = _documentsUrl.resolveUri(Uri( |
| path: '$collectionName/$documentName', |
| query: _currentTransaction == null |
| ? null |
| : 'transaction=${_escapedCurrentTransaction}')); |
| var response = await _client.get(url, headers: _headers); |
| if (response.statusCode == HttpStatus.ok) { |
| var document = jsonDecode(response.body); |
| if (document is! Map) { |
| throw _error(response, message: 'Expected a Map'); |
| } |
| return document; |
| } else { |
| throw _error(response); |
| } |
| } |
| |
| Future<Object> updateField(Map document, String field) async { |
| var url = |
| _apiUrl.resolve('${document["name"]}?updateMask.fieldPaths=$field'); |
| var response = |
| await _client.patch(url, headers: _headers, body: jsonEncode(document)); |
| if (response.statusCode == HttpStatus.ok) { |
| return jsonDecode(response.body); |
| } else { |
| throw _error(response); |
| } |
| } |
| |
| void beginTransaction() async { |
| if (_currentTransaction != null) { |
| throw Exception('Error: nested transactions'); |
| } |
| var body = '{"options": {}}'; |
| var response = |
| await _client.post(_beginTransactionUrl, headers: _headers, body: body); |
| if (response.statusCode == HttpStatus.ok) { |
| var result = jsonDecode(response.body); |
| _currentTransaction = result['transaction'] as String; |
| if (_currentTransaction == null) { |
| throw Exception("Call returned no transaction identifier"); |
| } |
| } else { |
| throw _error(response, message: 'Could not start transaction:'); |
| } |
| } |
| |
| Future<bool> commit([List<Write> writes]) async { |
| if (_currentTransaction == null) { |
| throw Exception('"commit" called without transaction'); |
| } |
| var body = jsonEncode({ |
| "writes": writes.map((write) => write.data).toList(), |
| "transaction": "$_currentTransaction" |
| }); |
| var response = |
| await _client.post(_commitUrl, headers: _headers, body: body); |
| _currentTransaction = null; |
| if (response.statusCode == HttpStatus.conflict) { |
| // This HTTP status code corresponds to the ABORTED error code, see |
| // https://cloud.google.com/datastore/docs/concepts/errors and |
| // https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto#L137 |
| return false; |
| } |
| if (response.statusCode != HttpStatus.ok) { |
| throw _error(response); |
| } |
| return true; |
| } |
| |
| Exception _error(http.Response response, {String message: 'Error'}) { |
| throw Exception('$message: ${response.statusCode}: ' |
| '${response.reasonPhrase}:\n${response.body}'); |
| } |
| |
| /// Closes the underlying HTTP client. |
| void closeClient() => _client.close(); |
| } |
| |
| abstract class Write { |
| Map get data; |
| } |
| |
| class Update implements Write { |
| final Map data; |
| Update(List<String> updateMask, Map document, {String updateTime}) |
| : data = { |
| if (updateTime != null) "currentDocument": {"updateTime": updateTime}, |
| "updateMask": {"fieldPaths": updateMask}, |
| "update": document |
| }; |
| } |
| |
| class Query { |
| final Map data; |
| Query(String collection, Filter filter, {int limit}) |
| : data = { |
| 'structuredQuery': { |
| 'from': [ |
| {'collectionId': collection} |
| ], |
| if (limit != null) 'limit': limit, |
| 'where': filter.data, |
| } |
| }; |
| } |
| |
| class Filter { |
| final Map data; |
| Filter(this.data); |
| } |
| |
| class FieldFilter extends Filter { |
| FieldFilter(String field, String op, String type, Object value) |
| : super({ |
| 'fieldFilter': { |
| 'field': {'fieldPath': field}, |
| 'op': op, |
| 'value': {'$type': value}, |
| } |
| }); |
| } |
| |
| class Field { |
| final String name; |
| Field(this.name); |
| FieldFilter equals(Value value) { |
| return FieldFilter(name, 'EQUAL', value.type, value.value); |
| } |
| |
| FieldFilter greaterOrEqual(Value value) { |
| return FieldFilter(name, 'GREATER_THAN_OR_EQUAL', value.type, value.value); |
| } |
| |
| FieldFilter lessOrEqual(Value value) { |
| return FieldFilter(name, 'LESS_THAN_OR_EQUAL', value.type, value.value); |
| } |
| |
| FieldFilter contains(Value value) { |
| return FieldFilter(name, 'ARRAY_CONTAINS', value.type, value.value); |
| } |
| } |
| |
| class Value { |
| final String type; |
| final Object value; |
| Value.boolean(bool this.value) : type = 'booleanValue'; |
| Value.string(String this.value) : type = 'stringValue'; |
| Value.integer(int this.value) : type = 'integerValue'; |
| } |
| |
| class CompositeFilter extends Filter { |
| CompositeFilter(String op, List<Filter> parts) |
| : super({ |
| 'compositeFilter': { |
| 'op': op, |
| 'filters': parts.map((part) => part.data).toList(), |
| } |
| }); |
| } |