├── test ├── unit │ ├── stream │ │ ├── sse_client_test_fallback.dart │ │ ├── server_sent_event_test.dart │ │ ├── vm │ │ │ ├── sse_client_test_vm.dart │ │ │ └── event_stream_decoder_test.dart │ │ ├── sse_client_test.dart │ │ └── js │ │ │ └── sse_client_test_js.dart │ ├── database │ │ ├── store_helpers │ │ │ ├── map_transform.dart │ │ │ ├── store_patchset_test.dart │ │ │ ├── callback_store_test.dart │ │ │ ├── store_key_event_transformer_test.dart │ │ │ ├── store_value_event_transformer_test.dart │ │ │ ├── store_transaction_test.dart │ │ │ └── store_event_transformer_test.dart │ │ ├── etag_receiver_test.dart │ │ ├── timestamp_test.dart │ │ ├── transaction_test.dart │ │ ├── auto_renew_stream_test.dart │ │ └── database_test.dart │ ├── common │ │ ├── api_constants_test.dart │ │ ├── filter_test.dart │ │ └── timeout_test.dart │ └── rest │ │ └── stream_event_transformer_test.dart ├── integration │ ├── test_config_js.dart │ └── test_config_vm.dart ├── test_data.dart └── stream_matcher_queue.dart ├── lib ├── src │ ├── .gitignore │ ├── stream │ │ ├── js │ │ │ ├── sse_client_js_factory.dart │ │ │ └── sse_client_js.dart │ │ ├── vm │ │ │ ├── sse_client_vm_factory.dart │ │ │ ├── sse_client_vm.dart │ │ │ └── event_stream_decoder.dart │ │ ├── server_sent_event.dart │ │ ├── sse_client_factory.dart │ │ └── sse_client.dart │ ├── database │ │ ├── auth_revoked_exception.dart │ │ ├── store_helpers │ │ │ ├── map_transform.dart │ │ │ ├── store_patchset.dart │ │ │ ├── callback_store.dart │ │ │ ├── store_transaction.dart │ │ │ ├── store_key_event_transformer.dart │ │ │ ├── store_value_event_transformer.dart │ │ │ └── store_event_transformer.dart │ │ ├── json_converter.dart │ │ ├── etag_receiver.dart │ │ ├── timestamp.dart │ │ ├── transaction.dart │ │ ├── auto_renew_stream.dart │ │ ├── store_event.dart │ │ └── database.dart │ ├── common │ │ ├── transformer_sink.dart │ │ ├── db_exception.dart │ │ ├── api_constants.dart │ │ ├── timeout.dart │ │ └── filter.dart │ └── rest │ │ ├── models │ │ ├── db_response.dart │ │ ├── unknown_stream_event_error.dart │ │ ├── post_response.dart │ │ └── stream_event.dart │ │ ├── stream_event_transformer.dart │ │ └── rest_api.dart ├── stream.dart ├── rest.dart └── firebase_database_rest.dart ├── dartdoc_options.yaml ├── .gitignore ├── pubspec.yaml ├── analysis_options.yaml ├── CHANGELOG.md ├── LICENSE ├── example └── firebase_database_rest_example.dart ├── .github └── workflows │ └── ci.yaml ├── README.md └── Makefile /test/unit/stream/sse_client_test_fallback.dart: -------------------------------------------------------------------------------- 1 | void setupTests() {} 2 | -------------------------------------------------------------------------------- /lib/src/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated dart files 2 | *.freezed.dart 3 | *.g.dart 4 | -------------------------------------------------------------------------------- /dartdoc_options.yaml: -------------------------------------------------------------------------------- 1 | dartdoc: 2 | exclude: 3 | - "*.g.dart" 4 | - "*.freezed.dart" 5 | -------------------------------------------------------------------------------- /lib/stream.dart: -------------------------------------------------------------------------------- 1 | export 'src/stream/server_sent_event.dart'; 2 | export 'src/stream/sse_client.dart'; 3 | -------------------------------------------------------------------------------- /lib/rest.dart: -------------------------------------------------------------------------------- 1 | export 'src/rest/models/db_response.dart'; 2 | export 'src/rest/models/post_response.dart'; 3 | export 'src/rest/models/stream_event.dart'; 4 | export 'src/rest/models/unknown_stream_event_error.dart'; 5 | export 'src/rest/rest_api.dart'; 6 | export 'src/rest/stream_event_transformer.dart' hide StreamEventTransformerSink; 7 | -------------------------------------------------------------------------------- /test/integration/test_config_js.dart: -------------------------------------------------------------------------------- 1 | part 'test_config_js.env.dart'; 2 | 3 | abstract class TestConfig { 4 | const TestConfig._(); 5 | 6 | static String get projectId => _firebaseProjectId; 7 | static String get apiKey => _firebaseApiKey; 8 | 9 | static int get allTestLimit => int.tryParse(_allTestLimit) ?? 5; 10 | } 11 | -------------------------------------------------------------------------------- /lib/src/stream/js/sse_client_js_factory.dart: -------------------------------------------------------------------------------- 1 | import 'package:http/http.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../sse_client.dart'; 5 | import 'sse_client_js.dart'; 6 | 7 | /// @nodoc 8 | @internal 9 | abstract class SSEClientFactory { 10 | const SSEClientFactory._(); 11 | 12 | /// @nodoc 13 | static SSEClient create(Client client) => SSEClientJS(client); 14 | } 15 | -------------------------------------------------------------------------------- /lib/src/stream/vm/sse_client_vm_factory.dart: -------------------------------------------------------------------------------- 1 | import 'package:http/http.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../sse_client.dart'; 5 | import 'sse_client_vm.dart'; 6 | 7 | /// @nodoc 8 | @internal 9 | abstract class SSEClientFactory { 10 | const SSEClientFactory._(); // coverage:ignore-line 11 | 12 | /// @nodoc 13 | static SSEClient create(Client client) => SSEClientVM(client); 14 | } 15 | -------------------------------------------------------------------------------- /test/integration/test_config_vm.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | abstract class TestConfig { 4 | const TestConfig._(); 5 | 6 | static String get projectId => Platform.environment['FIREBASE_PROJECT_ID']!; 7 | static String get apiKey => Platform.environment['FIREBASE_API_KEY']!; 8 | 9 | static int get allTestLimit => 10 | int.tryParse(Platform.environment['FIREBASE_ALL_TEST_LIMIT'] ?? '') ?? 5; 11 | } 12 | -------------------------------------------------------------------------------- /lib/src/database/auth_revoked_exception.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | import '../../firebase_database_rest.dart'; 3 | 4 | /// An exception that is thrown by [FirebaseStore] streams when the current 5 | /// authentication must be refreshed. 6 | class AuthRevokedException implements Exception { 7 | @override 8 | String toString() => 9 | 'Authentication credentials have expired or have been revoked'; 10 | } 11 | -------------------------------------------------------------------------------- /test/unit/stream/server_sent_event_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: prefer_const_constructors 2 | import 'package:firebase_database_rest/src/stream/server_sent_event.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | test('uses correct defaults', () { 7 | final sut = ServerSentEvent(data: 'data'); 8 | expect(sut.data, 'data'); 9 | expect(sut.event, 'message'); 10 | expect(sut.lastEventId, isNull); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files and directories created by pub 2 | .dart_tool/ 3 | .packages 4 | 5 | # Omit commiting pubspec.lock for library packages: 6 | # https://dart.dev/guides/libraries/private-files#pubspeclock 7 | pubspec.lock 8 | 9 | # Conventional directory for build outputs 10 | build/ 11 | 12 | # Directory created by dartdoc 13 | doc/api/ 14 | 15 | # Coverage 16 | coverage/ 17 | 18 | # Firebase config 19 | .env 20 | 21 | # Test stuff 22 | *.env.dart 23 | test/**/*.freezed.dart 24 | test/**/*.g.dart 25 | -------------------------------------------------------------------------------- /lib/src/common/transformer_sink.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:meta/meta.dart'; 4 | 5 | /// @nodoc 6 | @internal 7 | abstract class TransformerSink implements EventSink { 8 | /// @nodoc 9 | final EventSink outSink; 10 | 11 | /// @nodoc 12 | TransformerSink(this.outSink); 13 | 14 | @override 15 | void addError(Object error, [StackTrace? stackTrace]) => 16 | outSink.addError(error, stackTrace); 17 | 18 | @override 19 | void close() => outSink.close(); 20 | } 21 | -------------------------------------------------------------------------------- /lib/src/rest/models/db_response.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | import 'package:freezed_annotation/freezed_annotation.dart'; 3 | 4 | part 'db_response.freezed.dart'; 5 | 6 | /// A generic reponse for database methods 7 | @freezed 8 | class DbResponse with _$DbResponse { 9 | /// Default constructor 10 | const factory DbResponse({ 11 | /// The data that was returned by the server. 12 | required dynamic data, 13 | 14 | /// An optional ETag of the data, if it was requested. 15 | String? eTag, 16 | }) = _DbResponse; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/database/store_helpers/map_transform.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import '../store.dart'; 4 | 5 | /// @nodoc 6 | @internal 7 | mixin MapTransform { 8 | /// @nodoc 9 | @protected 10 | Map mapTransform( 11 | dynamic data, 12 | DataFromJsonCallback dataFromJson, 13 | ) { 14 | final transformedMap = {}; 15 | (data as Map?)?.forEach((key, dynamic value) { 16 | if (value != null) { 17 | transformedMap[key] = dataFromJson(value); 18 | } 19 | }); 20 | return transformedMap; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/src/rest/models/unknown_stream_event_error.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | import '../../stream/server_sent_event.dart'; 3 | 4 | /// An exception that is thrown if the firebase server sents an unexpected SSE. 5 | class UnknownStreamEventError extends Error { 6 | /// The event that has been received. 7 | final ServerSentEvent event; 8 | 9 | /// Default constructor. 10 | /// 11 | /// Created with the [event] that was received but not understood by this 12 | /// library. 13 | UnknownStreamEventError(this.event); 14 | 15 | @override 16 | String toString() => 'Received unknown server event: $event'; 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/rest/models/post_response.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | import 'package:freezed_annotation/freezed_annotation.dart'; 3 | 4 | part 'post_response.freezed.dart'; 5 | part 'post_response.g.dart'; 6 | 7 | /// A post response, returned by the server for POST requests 8 | @freezed 9 | class PostResponse with _$PostResponse { 10 | /// Default constructor 11 | const factory PostResponse( 12 | /// The name of the newly created entry in firebase 13 | String name, 14 | ) = _PostResponse; 15 | 16 | /// @nodoc 17 | factory PostResponse.fromJson(Map json) => 18 | _$PostResponseFromJson(json); 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/database/store_helpers/store_patchset.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../store.dart'; 4 | import '../store_event.dart'; 5 | 6 | part 'store_patchset.freezed.dart'; 7 | 8 | /// @nodoc 9 | @internal 10 | @freezed 11 | class StorePatchSet with _$StorePatchSet implements PatchSet { 12 | const StorePatchSet._(); 13 | 14 | /// @nodoc 15 | // ignore: sort_unnamed_constructors_first 16 | const factory StorePatchSet({ 17 | required FirebaseStore store, 18 | required Map data, 19 | }) = _StorePatchSet; 20 | 21 | @override 22 | // ignore: invalid_use_of_protected_member 23 | T apply(T value) => store.patchData(value, data); 24 | } 25 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: firebase_database_rest 2 | description: A platform independent Dart/Flutter wrapper for the Firebase 3 | Realtime Database API based on REST. 4 | version: 1.0.3 5 | homepage: https://github.com/Skycoder42/firebase_database_rest 6 | 7 | environment: 8 | sdk: ">=2.12.0 <3.0.0" 9 | 10 | dependencies: 11 | firebase_auth_rest: ^2.0.1 12 | freezed_annotation: ^0.14.1 13 | http: ^0.13.3 14 | json_annotation: ^4.0.1 15 | meta: ^1.3.0 16 | path: ^1.8.0 17 | 18 | dev_dependencies: 19 | build_runner: ^2.0.2 20 | coverage: ^1.0.2 21 | dart_pre_commit: ^2.3.0 22 | freezed: ^0.14.1+3 23 | json_serializable: ^4.1.1 24 | lint: ^1.5.3 25 | mocktail: ^0.1.2 26 | test: ^1.17.3 27 | test_api: ^0.4.0 28 | tuple: ^2.0.0 29 | -------------------------------------------------------------------------------- /lib/src/stream/server_sent_event.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'server_sent_event.freezed.dart'; 4 | 5 | /// A dataclass representing a Server Sent Event. 6 | @freezed 7 | class ServerSentEvent with _$ServerSentEvent { 8 | /// Default constructor 9 | const factory ServerSentEvent({ 10 | /// The event type of this SSE. Defaults to `message`, if not set. 11 | @Default('message') String event, 12 | 13 | /// The data that was sent by the server. Can be an empty string. 14 | required String data, 15 | 16 | /// An optional ID of the last event. 17 | /// 18 | /// Can be used to resume a stream if supported by the server. See 19 | /// [EventSourceClientX.stream]. 20 | String? lastEventId, 21 | }) = _ServerSentEvent; 22 | } 23 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lint/analysis_options_package.yaml 2 | 3 | analyzer: 4 | exclude: 5 | - "**/*.g.dart" 6 | - "**/*.freezed.dart" 7 | - "**/*.mocks.dart" 8 | - "build/**" 9 | - test/integration/test_config_js.dart 10 | strong-mode: 11 | implicit-casts: false 12 | implicit-dynamic: false 13 | errors: 14 | missing_return: error 15 | missing_required_param: error 16 | 17 | linter: 18 | rules: 19 | public_member_api_docs: true 20 | cascade_invocations: true 21 | close_sinks: true 22 | lines_longer_than_80_chars: true 23 | omit_local_variable_types: true 24 | only_throw_errors: true 25 | prefer_adjacent_string_concatenation: true 26 | prefer_expression_function_bodies: true 27 | prefer_single_quotes: true 28 | unawaited_futures: true 29 | -------------------------------------------------------------------------------- /test/unit/database/store_helpers/map_transform.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_database_rest/src/database/store_helpers/map_transform.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | class Sut with MapTransform { 5 | Map call(dynamic data) => mapTransform( 6 | data, 7 | (dynamic x) => (x as int) * 2, 8 | ); 9 | } 10 | 11 | void main() { 12 | late Sut sut; 13 | 14 | setUp(() { 15 | sut = Sut(); 16 | }); 17 | 18 | test('mapTransform transforms map', () { 19 | final res = sut({ 20 | 'a': 1, 21 | 'b': 2, 22 | 'c': null, 23 | 'd': 4, 24 | }); 25 | 26 | expect(res, { 27 | 'a': 2, 28 | 'b': 4, 29 | 'd': 8, 30 | }); 31 | }); 32 | 33 | test('mapTransform', () { 34 | final res = sut(null); 35 | expect(res, const {}); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /test/test_data.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | @isTestGroup 5 | void testData( 6 | dynamic description, 7 | List fixtures, 8 | dynamic Function(T fixture) body, { 9 | String? testOn, 10 | Timeout? timeout, 11 | dynamic skip, 12 | dynamic tags, 13 | Map? onPlatform, 14 | int? retry, 15 | String Function(T fixture)? fixtureToString, 16 | }) { 17 | assert(fixtures.isNotEmpty); 18 | group(description, () { 19 | for (final fixture in fixtures) { 20 | test( 21 | fixtureToString != null ? fixtureToString(fixture) : '[$fixture]', 22 | () => body(fixture), 23 | testOn: testOn, 24 | timeout: timeout, 25 | skip: skip, 26 | tags: tags, 27 | onPlatform: onPlatform, 28 | retry: retry, 29 | ); 30 | } 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /lib/firebase_database_rest.dart: -------------------------------------------------------------------------------- 1 | export 'src/common/api_constants.dart'; 2 | export 'src/common/db_exception.dart'; 3 | export 'src/common/filter.dart'; 4 | export 'src/common/timeout.dart'; 5 | export 'src/database/auth_revoked_exception.dart'; 6 | export 'src/database/auto_renew_stream.dart'; 7 | export 'src/database/database.dart'; 8 | export 'src/database/etag_receiver.dart'; 9 | export 'src/database/json_converter.dart'; 10 | export 'src/database/store.dart'; 11 | export 'src/database/store_event.dart'; 12 | export 'src/database/store_helpers/store_event_transformer.dart' 13 | hide StoreEventTransformerSink; 14 | export 'src/database/store_helpers/store_key_event_transformer.dart' 15 | hide StoreKeyEventTransformerSink; 16 | export 'src/database/store_helpers/store_value_event_transformer.dart' 17 | hide StoreValueEventTransformerSink; 18 | export 'src/database/timestamp.dart'; 19 | export 'src/database/transaction.dart'; 20 | -------------------------------------------------------------------------------- /lib/src/database/json_converter.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | /// A small interface for classes that can be used as a json converter for [T] 4 | abstract class JsonConverter { 5 | const JsonConverter._(); 6 | 7 | /// A virtual method that converts a [json] object to a data type. 8 | /// 9 | /// The [json] beeing passed to this method can never be `null`. 10 | T dataFromJson(dynamic json); 11 | 12 | /// A virtual method that converts a [data] type to a json object. 13 | /// 14 | /// The json beeing returned from this method **must never** be `null`. 15 | dynamic dataToJson(T data); 16 | 17 | /// A virtual method that applies a set of [updatedFields] on existing [data]. 18 | /// 19 | /// This should return a copy of [data], with all fields that appear in 20 | /// [updatedFields] updated to the respective value. Any fields that do not 21 | /// appear in [updatedFields] should stay unchanged. 22 | T patchData(T data, Map updatedFields); 23 | } 24 | -------------------------------------------------------------------------------- /test/unit/database/etag_receiver_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_database_rest/src/database/etag_receiver.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | late ETagReceiver sut; 6 | 7 | setUp(() { 8 | sut = ETagReceiver(); 9 | }); 10 | 11 | test('eTag is initially null', () { 12 | expect(sut.eTag, isNull); 13 | }); 14 | 15 | test('can set eTag', () { 16 | sut.eTag = 'new_etag'; 17 | expect(sut.eTag, 'new_etag'); 18 | }); 19 | 20 | test('equals and hashCode work correctly', () { 21 | final sut2 = ETagReceiver(); 22 | final sut3 = ETagReceiver(); 23 | sut.eTag = '2'; 24 | sut2.eTag = '2'; 25 | sut3.eTag = '3'; 26 | 27 | expect(sut, sut2); 28 | expect(sut.hashCode, sut2.hashCode); 29 | expect(sut, isNot(sut3)); 30 | expect(sut.hashCode, isNot(sut3.hashCode)); 31 | }); 32 | 33 | test('toString prints eTag', () { 34 | sut.eTag = 'e_tag'; 35 | expect(sut.toString(), 'ETag: e_tag'); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /test/unit/database/store_helpers/store_patchset_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_database_rest/src/database/store.dart'; 2 | import 'package:firebase_database_rest/src/database/store_helpers/store_patchset.dart'; 3 | import 'package:mocktail/mocktail.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | class MockFirebaseStore extends Mock implements FirebaseStore {} 7 | 8 | void main() { 9 | const patchData = {'a': 1, 'b': 2}; 10 | final mockFirebaseStore = MockFirebaseStore(); 11 | 12 | late StorePatchSet sut; 13 | 14 | setUp(() { 15 | reset(mockFirebaseStore); 16 | 17 | sut = StorePatchSet( 18 | store: mockFirebaseStore, 19 | data: patchData, 20 | ); 21 | }); 22 | 23 | test('apply calls patchData on store with data', () { 24 | when(() => mockFirebaseStore.patchData(any(), any())).thenReturn(42); 25 | 26 | final res = sut.apply(13); 27 | 28 | expect(res, 42); 29 | verify(() => mockFirebaseStore.patchData(13, patchData)); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.0.3] - 2021-05-07 8 | ### Added 9 | - JavaScript/Web CI tests 10 | ### Changed 11 | - Changed mocking framework to mocktail 12 | - Improve build scripts 13 | ### Fixed 14 | - SSE streaming of database updates did not work in Webbrowsers (#1) 15 | ### Security 16 | - Updated dependency requirements 17 | 18 | ## [1.0.2] - 2021-03-11 19 | ### Fixed 20 | - Add `JsonConverter` to library exports 21 | 22 | ## [1.0.1] - 2021-03-11 23 | ### Added 24 | - `JsonConverter` interface 25 | ### Changed 26 | - Made json converter methods public 27 | 28 | ## [1.0.0] - 2021-03-10 29 | ### Added 30 | - Initial release 31 | 32 | ## [Unreleased] 33 | ### Added 34 | ### Changed 35 | ### Deprecated 36 | ### Removed 37 | ### Fixed 38 | ### Security 39 | -------------------------------------------------------------------------------- /lib/src/database/etag_receiver.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../firebase_database_rest.dart'; 4 | 5 | /// A helper class to obtain ETags from [FirebaseStore] requests. 6 | class ETagReceiver { 7 | String? _eTag; 8 | 9 | /// The [eTag] that was returned by the server 10 | /// 11 | /// This value is initially `null`, until set by the [FirebaseStore] after a 12 | /// request. If the request succeeds, the receivers eTag should contain the 13 | /// value and never be `null`. 14 | // ignore: unnecessary_getters_setters 15 | String? get eTag => _eTag; 16 | 17 | @internal 18 | // ignore: unnecessary_getters_setters 19 | set eTag(String? eTag) => _eTag = eTag; 20 | 21 | @override 22 | String toString() => 'ETag: $eTag'; 23 | 24 | @override 25 | bool operator ==(Object other) { 26 | if (identical(this, other)) { 27 | return true; 28 | } 29 | 30 | return other is ETagReceiver && _eTag == other._eTag; 31 | } 32 | 33 | @override 34 | int get hashCode => runtimeType.hashCode ^ _eTag.hashCode; 35 | } 36 | -------------------------------------------------------------------------------- /test/unit/database/timestamp_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_database_rest/src/common/api_constants.dart'; 2 | import 'package:firebase_database_rest/src/database/timestamp.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | test('default constructor wraps datetime', () { 7 | final dateTime = DateTime(2021, 11, 11, 4, 6, 8, 357); 8 | final timestamp = FirebaseTimestamp(dateTime); 9 | 10 | expect(timestamp.dateTime, dateTime); 11 | expect(timestamp.toJson(), dateTime.millisecondsSinceEpoch); 12 | 13 | final jStamp = FirebaseTimestamp.fromJson(dateTime.millisecondsSinceEpoch); 14 | expect(jStamp, timestamp); 15 | }); 16 | 17 | test('server constructor wraps server timestamp placeholder', () { 18 | const timestamp = FirebaseTimestamp.server(); 19 | 20 | expect(() => timestamp.dateTime, throwsA(isA())); 21 | expect(timestamp.toJson(), ApiConstants.serverTimeStamp); 22 | 23 | expect( 24 | () => FirebaseTimestamp.fromJson(ApiConstants.serverTimeStamp), 25 | throwsA(isA()), 26 | ); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/rest/models/stream_event.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | import 'package:freezed_annotation/freezed_annotation.dart'; 3 | 4 | part 'stream_event.freezed.dart'; 5 | part 'stream_event.g.dart'; 6 | 7 | /// A generic stream event, returned by the server via SSE. 8 | @freezed 9 | class StreamEvent with _$StreamEvent { 10 | /// A put event, indicating data was created, updated or deleted. 11 | const factory StreamEvent.put({ 12 | /// The sub path to the request were data was modified. 13 | required String path, 14 | 15 | /// The data that has been modified. 16 | required dynamic data, 17 | }) = StreamEventPut; 18 | 19 | /// A patch event, indicating data was patched. 20 | const factory StreamEvent.patch({ 21 | /// The sub path to the request were data was modified. 22 | required String path, 23 | 24 | /// The patchset that was sent by the client to modify the server data. 25 | required dynamic data, 26 | }) = StreamEventPatch; 27 | 28 | /// An event sent by the server when the used idToken has expired. 29 | const factory StreamEvent.authRevoked() = StreamEventAuthRevoked; 30 | 31 | /// @nodoc 32 | factory StreamEvent.fromJson(Map json) => 33 | _$StreamEventFromJson(json); 34 | } 35 | -------------------------------------------------------------------------------- /test/unit/common/api_constants_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_database_rest/src/common/api_constants.dart'; 2 | import 'package:test/test.dart'; 3 | import 'package:tuple/tuple.dart'; 4 | 5 | import '../../test_data.dart'; 6 | 7 | void main() { 8 | testData>( 9 | 'PrintMode provides correct values', 10 | const [ 11 | Tuple2(PrintMode.pretty, 'pretty'), 12 | Tuple2(PrintMode.silent, 'silent'), 13 | ], 14 | (fixture) { 15 | expect(fixture.item1.value, fixture.item2); 16 | }, 17 | ); 18 | 19 | testData>( 20 | 'FormatMode provides correct values', 21 | const [ 22 | Tuple2(FormatMode.export, 'export'), 23 | ], 24 | (fixture) { 25 | expect(fixture.item1.value, fixture.item2); 26 | }, 27 | ); 28 | 29 | testData>( 30 | 'WriteSizeLimit provides correct values', 31 | const [ 32 | Tuple2(WriteSizeLimit.tiny, 'tiny'), 33 | Tuple2(WriteSizeLimit.small, 'small'), 34 | Tuple2(WriteSizeLimit.medium, 'medium'), 35 | Tuple2(WriteSizeLimit.large, 'large'), 36 | Tuple2(WriteSizeLimit.unlimited, 'unlimited'), 37 | ], 38 | (fixture) { 39 | expect(fixture.item1.value, fixture.item2); 40 | }, 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /lib/src/common/db_exception.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | import 'package:freezed_annotation/freezed_annotation.dart'; 3 | 4 | import 'api_constants.dart'; 5 | 6 | part 'db_exception.freezed.dart'; 7 | part 'db_exception.g.dart'; 8 | 9 | /// A generic exception representing an error when accessing the realtime 10 | /// database 11 | /// 12 | /// This error is thrown everywhere in the library, where the realtime database 13 | /// is beeing accessed. It wrapps the HTTP-Errors that can happen when using 14 | /// the REST-API, including the HTTP-Status code. 15 | @freezed 16 | class DbException with _$DbException implements Exception { 17 | const DbException._(); 18 | 19 | /// Default constructor 20 | /// 21 | /// Generates an exception from the HTTP [statusCode] and an [error] message 22 | // ignore: sort_unnamed_constructors_first 23 | const factory DbException({ 24 | /// The HTTP status code returned by the request 25 | @Default(400) int statusCode, 26 | 27 | /// An optional error message, if the firebase servers provided one. 28 | String? error, 29 | }) = _Exception; 30 | 31 | /// Checks if this error is the virtual "event stream canceled" error event 32 | bool get isEventStreamCanceled => 33 | statusCode == ApiConstants.eventStreamCanceled; 34 | 35 | /// @nodoc 36 | factory DbException.fromJson(Map json) => 37 | _$DbExceptionFromJson(json); 38 | } 39 | -------------------------------------------------------------------------------- /lib/src/database/store_helpers/callback_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import '../../rest/rest_api.dart'; 4 | import '../store.dart'; 5 | 6 | /// @nodoc 7 | @internal 8 | class CallbackFirebaseStore extends FirebaseStore { 9 | /// @nodoc 10 | final DataFromJsonCallback onDataFromJson; 11 | 12 | /// @nodoc 13 | final DataToJsonCallback onDataToJson; 14 | 15 | /// @nodoc 16 | final PatchDataCallback onPatchData; 17 | 18 | /// @nodoc 19 | CallbackFirebaseStore({ 20 | required FirebaseStore parent, 21 | required String path, 22 | required this.onDataFromJson, 23 | required this.onDataToJson, 24 | required this.onPatchData, 25 | }) : super( 26 | parent: parent, 27 | path: path, 28 | ); 29 | 30 | /// @nodoc 31 | CallbackFirebaseStore.api({ 32 | required RestApi restApi, 33 | required List subPaths, 34 | required this.onDataFromJson, 35 | required this.onDataToJson, 36 | required this.onPatchData, 37 | }) : super.api( 38 | restApi: restApi, 39 | subPaths: subPaths, 40 | ); 41 | 42 | @override 43 | T dataFromJson(dynamic json) => onDataFromJson(json); 44 | 45 | @override 46 | dynamic dataToJson(T data) => onDataToJson(data); 47 | 48 | @override 49 | T patchData(T data, Map updatedFields) => 50 | onPatchData(data, updatedFields); 51 | } 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Felix Barz 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /lib/src/database/store_helpers/store_transaction.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../common/api_constants.dart'; 4 | import '../../common/db_exception.dart'; 5 | import '../etag_receiver.dart'; 6 | import '../store.dart'; 7 | import '../transaction.dart'; 8 | 9 | /// @nodoc 10 | @internal 11 | class StoreTransaction extends SingleCommitTransaction { 12 | /// @nodoc 13 | final FirebaseStore store; 14 | 15 | @override 16 | final String key; 17 | 18 | @override 19 | final T? value; 20 | 21 | @override 22 | final String eTag; 23 | 24 | /// @nodoc 25 | final ETagReceiver? eTagReceiver; 26 | 27 | /// @nodoc 28 | StoreTransaction({ 29 | required this.store, 30 | required this.key, 31 | required this.value, 32 | required this.eTag, 33 | required this.eTagReceiver, 34 | }); 35 | 36 | @override 37 | Future commitUpdateImpl(T data) async { 38 | try { 39 | return await store.write( 40 | key, 41 | data, 42 | eTag: eTag, 43 | eTagReceiver: eTagReceiver, 44 | ); 45 | } on DbException catch (e) { 46 | if (e.statusCode == ApiConstants.statusCodeETagMismatch) { 47 | throw const TransactionFailedException(); 48 | } else { 49 | rethrow; 50 | } 51 | } 52 | } 53 | 54 | @override 55 | Future commitDeleteImpl() async { 56 | try { 57 | await store.delete( 58 | key, 59 | eTag: eTag, 60 | eTagReceiver: eTagReceiver, 61 | ); 62 | } on DbException catch (e) { 63 | if (e.statusCode == ApiConstants.statusCodeETagMismatch) { 64 | throw const TransactionFailedException(); 65 | } else { 66 | rethrow; 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /test/unit/common/filter_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_database_rest/src/common/filter.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('order', () { 6 | test('property sets orderBy to name', () { 7 | final filters = Filter.property('name').build(); 8 | 9 | expect(filters.filters, const { 10 | 'orderBy': '"name"', 11 | }); 12 | }); 13 | 14 | test(r'key sets orderBy to $key', () { 15 | final filters = Filter.key().build(); 16 | 17 | expect(filters.filters, const { 18 | 'orderBy': r'"$key"', 19 | }); 20 | }); 21 | 22 | test(r'value sets orderBy to $value', () { 23 | final filters = Filter.value().build(); 24 | 25 | expect(filters.filters, const { 26 | 'orderBy': r'"$value"', 27 | }); 28 | }); 29 | }); 30 | 31 | group('filter', () { 32 | test('limitToFirst sets value as query parameter', () { 33 | final filters = Filter.key().limitToFirst(10).build(); 34 | 35 | expect(filters.filters, const { 36 | 'orderBy': r'"$key"', 37 | 'limitToFirst': '10', 38 | }); 39 | }); 40 | 41 | test('limitToLast sets value as query parameter', () { 42 | final filters = Filter.key().limitToLast(10).build(); 43 | 44 | expect(filters.filters, const { 45 | 'orderBy': r'"$key"', 46 | 'limitToLast': '10', 47 | }); 48 | }); 49 | 50 | test('startAt sets value as query parameter', () { 51 | final filters = Filter.key().startAt('A').build(); 52 | 53 | expect(filters.filters, const { 54 | 'orderBy': r'"$key"', 55 | 'startAt': '"A"', 56 | }); 57 | }); 58 | 59 | test('endAt sets value as query parameter', () { 60 | final filters = Filter.key().endAt('A').build(); 61 | 62 | expect(filters.filters, const { 63 | 'orderBy': r'"$key"', 64 | 'endAt': '"A"', 65 | }); 66 | }); 67 | 68 | test('equalTo sets value as query parameter', () { 69 | final filters = Filter.key().equalTo('A').build(); 70 | 71 | expect(filters.filters, const { 72 | 'orderBy': r'"$key"', 73 | 'equalTo': '"A"', 74 | }); 75 | }); 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /lib/src/database/timestamp.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../common/api_constants.dart'; 4 | 5 | part 'timestamp.freezed.dart'; 6 | 7 | /// A virtual timestamp that can either be a [DateTime] or a server set value. 8 | /// 9 | /// If you want to use a server timestamp for a database entry, you should use 10 | /// this class instead of [DateTime]. It can be either set to a date time, 11 | /// allowing you to set an actual time, or to [FirebaseTimestamp.server], which 12 | /// is a placeholder that will be replaced by the server time upon beeing 13 | /// stored. 14 | @freezed 15 | class FirebaseTimestamp with _$FirebaseTimestamp { 16 | const FirebaseTimestamp._(); 17 | 18 | /// Creates a timestamp from a [DateTime] value. 19 | // ignore: sort_unnamed_constructors_first 20 | const factory FirebaseTimestamp( 21 | /// The datetime value to be used. 22 | DateTime value, 23 | ) = _FirebaseTimestamp; 24 | 25 | /// Creates a timestamp placeholder that will be set to an actual [DateTime] 26 | /// upon beeing stored on the server. 27 | const factory FirebaseTimestamp.server() = _Server; 28 | 29 | /// @nodoc 30 | factory FirebaseTimestamp.fromJson(dynamic json) { 31 | if (json is! int) { 32 | throw ArgumentError.value( 33 | json, 34 | 'json', 35 | 'Cannot deserialize a server timestamp placeholder', 36 | ); 37 | } 38 | return FirebaseTimestamp(DateTime.fromMillisecondsSinceEpoch(json)); 39 | } 40 | 41 | /// @nodoc 42 | dynamic toJson() => when( 43 | (value) => value.millisecondsSinceEpoch, 44 | server: () => ApiConstants.serverTimeStamp, 45 | ); 46 | 47 | /// Returns the datetime value of the timestamp. 48 | /// 49 | /// If used on a [FirebaseTimestamp.server], it will throw an error. Otherwise 50 | /// the datetime value is returned. 51 | /// 52 | /// **Note:** Timestamps returned from the database server are always actual 53 | /// datetime values and can be savely deconstructed. Only those exlicitly 54 | /// created as server timestamp can throw. 55 | DateTime get dateTime => when( 56 | (value) => value, 57 | server: () => throw UnsupportedError( 58 | 'cannot call dateTime on a server timestamp', 59 | ), 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /lib/src/stream/vm/sse_client_vm.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | 4 | import 'package:http/http.dart'; 5 | import 'package:meta/meta.dart'; 6 | 7 | import '../server_sent_event.dart'; 8 | import '../sse_client.dart'; 9 | import '../sse_client_factory.dart'; 10 | import 'event_stream_decoder.dart'; 11 | 12 | class _SSEExceptionVM implements SSEException { 13 | final Response _response; 14 | 15 | _SSEExceptionVM(this._response); 16 | 17 | // coverage:ignore-start 18 | @override 19 | String toString() => _response.body; 20 | // coverage:ignore-end 21 | } 22 | 23 | class _SSEStreamVM extends SSEStream { 24 | final _filters = {}; 25 | final Stream _stream; 26 | 27 | _SSEStreamVM(this._stream); 28 | 29 | @override 30 | StreamSubscription listen( 31 | void Function(ServerSentEvent event)? onData, { 32 | Function? onError, 33 | void Function()? onDone, 34 | bool? cancelOnError, 35 | }) => 36 | _stream.where((event) => _filters.contains(event.event)).listen( 37 | onData, 38 | onError: onError, 39 | onDone: onDone, 40 | cancelOnError: cancelOnError, 41 | ); 42 | 43 | @override 44 | void addEventType(String event) => _filters.add(event); 45 | 46 | @override 47 | bool removeEventType(String event) => _filters.remove(event); 48 | } 49 | 50 | /// @nodoc 51 | @internal 52 | class SSEClientVM with ClientProxy implements SSEClient { 53 | @override 54 | final Client client; 55 | 56 | /// @nodoc 57 | SSEClientVM(this.client); 58 | 59 | @override 60 | Future stream(Uri url) async { 61 | final request = Request('GET', url) 62 | ..persistentConnection = true 63 | ..followRedirects = true 64 | ..headers['Accept'] = 'text/event-stream' 65 | ..headers['Cache-Control'] = 'no-cache'; 66 | 67 | final response = await client.send(request); 68 | if (response.statusCode != 200) { 69 | return _SSEStreamVM(Stream.error( 70 | _SSEExceptionVM(await Response.fromStream(response)), 71 | )); 72 | } 73 | 74 | return _SSEStreamVM( 75 | response.stream 76 | .transform(utf8.decoder) 77 | .transform(const LineSplitter()) 78 | .transform(const EventStreamDecoder()), 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/src/common/api_constants.dart: -------------------------------------------------------------------------------- 1 | /// Formats the data returned in the response from the server. 2 | enum PrintMode { 3 | /// View the data in a human-readable format. 4 | pretty, 5 | 6 | /// Used to suppress the output from the server when writing data. The 7 | /// resulting response will be empty and indicated by a 204 No Content HTTP 8 | /// status code. 9 | silent, 10 | } 11 | 12 | /// Extension on [PrintMode] to get the server expected string 13 | extension PrintModeX on PrintMode { 14 | /// Returns the value of the given element 15 | String get value => toString().split('.').last; 16 | } 17 | 18 | /// Specifies how the server should format the reponse 19 | enum FormatMode { 20 | /// Include priority information in the response. 21 | export, 22 | } 23 | 24 | /// Extension on [FormatMode] to get the server expected string 25 | extension FormatModeX on FormatMode { 26 | /// Returns the value of the given element 27 | String get value => toString().split('.').last; 28 | } 29 | 30 | /// Realtime Database estimates the size of each write request and aborts 31 | /// requests that will take longer than the target time. 32 | enum WriteSizeLimit { 33 | /// target=1s 34 | tiny, 35 | 36 | /// target=10s 37 | small, 38 | 39 | /// target=30s 40 | medium, 41 | 42 | /// target=60s 43 | large, 44 | 45 | /// Exceptionally large writes (with up to 256MB payload) are allowed 46 | unlimited, 47 | } 48 | 49 | /// Extension on [WriteSizeLimit] to get the server expected string 50 | extension WriteSizeLimitX on WriteSizeLimit { 51 | /// Returns the value of the given element 52 | String get value => toString().split('.').last; 53 | } 54 | 55 | /// Various constants, relevant for the Realtime database API 56 | abstract class ApiConstants { 57 | const ApiConstants._(); // coverage:ignore-line 58 | 59 | /// The time since UNIX epoch, in milliseconds. 60 | static const serverTimeStamp = {'.sv': 'timestamp'}; 61 | 62 | /// 412 Precondition Failed 63 | /// 64 | /// The request's specified ETag value in the if-match header did not match 65 | /// the server's value. 66 | static const statusCodeETagMismatch = 412; 67 | 68 | /// ETag that indicates a null value at the server. 69 | static const nullETag = 'null_etag'; 70 | 71 | /// Internal error code, used for remotely canceled server streams. 72 | static const eventStreamCanceled = 542; 73 | } 74 | -------------------------------------------------------------------------------- /lib/src/database/store_helpers/store_key_event_transformer.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:meta/meta.dart'; 4 | 5 | import '../../common/transformer_sink.dart'; 6 | import '../../rest/models/stream_event.dart'; 7 | import '../auth_revoked_exception.dart'; 8 | import '../store_event.dart'; 9 | 10 | /// @nodoc 11 | @internal 12 | class StoreKeyEventTransformerSink 13 | extends TransformerSink { 14 | static final _subPathRegexp = RegExp(r'^\/([^\/]+)$'); 15 | 16 | /// @nodoc 17 | StoreKeyEventTransformerSink(EventSink outSink) : super(outSink); 18 | 19 | @override 20 | void add(StreamEvent event) => event.when( 21 | put: _put, 22 | patch: _value, 23 | authRevoked: _authRevoked, 24 | ); 25 | 26 | void _put(String path, dynamic data) { 27 | if (path == '/') { 28 | _reset(data); 29 | } else { 30 | _value(path, data); 31 | } 32 | } 33 | 34 | void _reset(dynamic data) { 35 | final map = data as Map?; 36 | final keys = map?.entries 37 | .where((entry) => entry.value != null) 38 | .map((entry) => entry.key) 39 | .toList(); 40 | outSink.add(KeyEvent.reset(keys ?? const [])); 41 | } 42 | 43 | void _value(String path, dynamic data) { 44 | final match = _subPathRegexp.firstMatch(path); 45 | if (match != null) { 46 | if (data == null) { 47 | outSink.add(KeyEvent.delete(match[1]!)); 48 | } else { 49 | outSink.add(KeyEvent.update(match[1]!)); 50 | } 51 | } else { 52 | outSink.add(KeyEvent.invalidPath(path)); 53 | } 54 | } 55 | 56 | void _authRevoked() => addError(AuthRevokedException()); 57 | } 58 | 59 | /// A stream transformer that converts a stream of [StreamEvent]s into a 60 | /// stream of [KeyEvent]s, deserializing the received data and turing database 61 | /// status updates into key updates. 62 | /// 63 | /// **Note:** Typically, you would use [FirebaseStore.streamKeys] instead of 64 | /// using this class directly. 65 | class StoreKeyEventTransformer 66 | implements StreamTransformer { 67 | /// Default constructor 68 | const StoreKeyEventTransformer(); 69 | 70 | @override 71 | Stream bind(Stream stream) => Stream.eventTransformed( 72 | stream, 73 | (sink) => StoreKeyEventTransformerSink(sink), 74 | ); 75 | 76 | @override 77 | StreamTransformer cast() => 78 | StreamTransformer.castFrom(this); 79 | } 80 | -------------------------------------------------------------------------------- /test/unit/common/timeout_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: prefer_const_constructors 2 | import 'package:firebase_database_rest/src/common/timeout.dart'; 3 | import 'package:test/test.dart' hide Timeout; 4 | import 'package:tuple/tuple.dart'; 5 | 6 | import '../../test_data.dart'; 7 | 8 | void main() { 9 | testData>( 10 | 'constructs correct timeouts from unit constructors', const [ 11 | Tuple3(Timeout.ms(10), Duration(milliseconds: 10), '10ms'), 12 | Tuple3(Timeout.ms(20000), Duration(seconds: 20), '20000ms'), 13 | Tuple3(Timeout.s(10), Duration(seconds: 10), '10s'), 14 | Tuple3(Timeout.s(180), Duration(minutes: 3), '180s'), 15 | Tuple3(Timeout.min(10), Duration(minutes: 10), '10min'), 16 | ], (fixture) { 17 | expect(fixture.item1.duration, fixture.item2); 18 | expect(fixture.item1.toString(), fixture.item3); 19 | }); 20 | 21 | testData>( 22 | 'fromDuration converts to correct timeout', const [ 23 | Tuple2(Duration(milliseconds: 60), Timeout.ms(60)), 24 | Tuple2(Duration(milliseconds: 6000), Timeout.s(6)), 25 | Tuple2(Duration(milliseconds: 6500), Timeout.ms(6500)), 26 | Tuple2(Duration(milliseconds: 60000), Timeout.min(1)), 27 | Tuple2(Duration(milliseconds: 63000), Timeout.s(63)), 28 | Tuple2(Duration(milliseconds: 63500), Timeout.ms(63500)), 29 | ], (fixture) { 30 | final t = Timeout.fromDuration(fixture.item1); 31 | expect(t, fixture.item2); 32 | expect(t.duration, fixture.item1); 33 | }); 34 | 35 | test( 36 | 'Limits Timeouts to positive times up to 15 minutes', 37 | () { 38 | expect(() => Timeout.ms(-5), throwsA(isA())); 39 | expect( 40 | () => Timeout.ms(15 * 60 * 1000 + 1), 41 | throwsA(isA()), 42 | ); 43 | expect(() => Timeout.s(-5), throwsA(isA())); 44 | expect(() => Timeout.s(15 * 60 + 1), throwsA(isA())); 45 | expect(() => Timeout.min(-5), throwsA(isA())); 46 | expect(() => Timeout.min(15 + 1), throwsA(isA())); 47 | expect( 48 | () => Timeout.fromDuration(const Duration(microseconds: 10)), 49 | throwsA(isA()), 50 | ); 51 | expect( 52 | () => Timeout.fromDuration(const Duration(minutes: 20)), 53 | throwsA(isA()), 54 | ); 55 | }, 56 | onPlatform: { 57 | 'browser': [ 58 | Skip('Freezed asserts do not work in the browser yet'), 59 | ] 60 | }, 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /lib/src/stream/sse_client_factory.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:http/http.dart'; 5 | import 'package:meta/meta.dart'; 6 | 7 | import 'sse_client.dart'; 8 | 9 | /// @nodoc 10 | @internal 11 | abstract class SSEClientFactory { 12 | const SSEClientFactory._(); // coverage:ignore-line 13 | 14 | /// @nodoc 15 | static SSEClient create(Client _client) => throw UnimplementedError( 16 | 'There is no default implementation of the SSEClient for this platform', 17 | ); 18 | } 19 | 20 | /// @nodoc 21 | @internal 22 | mixin ClientProxy implements Client { 23 | /// @nodoc 24 | @visibleForOverriding 25 | Client get client; 26 | 27 | @override 28 | void close() => client.close(); 29 | 30 | @override 31 | Future delete( 32 | Uri url, { 33 | Map? headers, 34 | Object? body, 35 | Encoding? encoding, 36 | }) => 37 | client.delete( 38 | url, 39 | headers: headers, 40 | body: body, 41 | encoding: encoding, 42 | ); 43 | 44 | @override 45 | Future get(Uri url, {Map? headers}) => 46 | client.get(url, headers: headers); 47 | 48 | @override 49 | Future head(Uri url, {Map? headers}) => 50 | client.head(url, headers: headers); 51 | 52 | @override 53 | Future patch( 54 | Uri url, { 55 | Map? headers, 56 | Object? body, 57 | Encoding? encoding, 58 | }) => 59 | client.patch( 60 | url, 61 | headers: headers, 62 | body: body, 63 | encoding: encoding, 64 | ); 65 | 66 | @override 67 | Future post( 68 | Uri url, { 69 | Map? headers, 70 | Object? body, 71 | Encoding? encoding, 72 | }) => 73 | client.post( 74 | url, 75 | headers: headers, 76 | body: body, 77 | encoding: encoding, 78 | ); 79 | 80 | @override 81 | Future put( 82 | Uri url, { 83 | Map? headers, 84 | Object? body, 85 | Encoding? encoding, 86 | }) => 87 | client.put( 88 | url, 89 | headers: headers, 90 | body: body, 91 | encoding: encoding, 92 | ); 93 | 94 | @override 95 | Future read(Uri url, {Map? headers}) => 96 | client.read(url, headers: headers); 97 | 98 | @override 99 | Future readBytes(Uri url, {Map? headers}) => 100 | client.readBytes(url, headers: headers); 101 | 102 | @override 103 | Future send(BaseRequest request) => client.send(request); 104 | } 105 | -------------------------------------------------------------------------------- /lib/src/rest/stream_event_transformer.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | 4 | import 'package:meta/meta.dart'; 5 | 6 | import '../common/api_constants.dart'; 7 | import '../common/db_exception.dart'; 8 | import '../common/transformer_sink.dart'; 9 | import '../stream/server_sent_event.dart'; 10 | import 'models/stream_event.dart'; 11 | import 'models/unknown_stream_event_error.dart'; 12 | 13 | /// @nodoc 14 | @internal 15 | class StreamEventTransformerSink 16 | extends TransformerSink { 17 | /// @nodoc 18 | StreamEventTransformerSink(EventSink outSink) : super(outSink); 19 | 20 | @override 21 | void add(ServerSentEvent event) { 22 | switch (event.event) { 23 | case 'put': 24 | outSink.add(StreamEventPut.fromJson( 25 | json.decode(event.data) as Map, 26 | )); 27 | break; 28 | case 'patch': 29 | outSink.add(StreamEventPatch.fromJson( 30 | json.decode(event.data) as Map, 31 | )); 32 | break; 33 | case 'keep-alive': 34 | break; // no-op 35 | case 'cancel': 36 | outSink.addError(DbException( 37 | statusCode: ApiConstants.eventStreamCanceled, 38 | error: event.data, 39 | )); 40 | break; 41 | case 'auth_revoked': 42 | outSink.add(const StreamEvent.authRevoked()); 43 | break; 44 | default: 45 | outSink.addError(UnknownStreamEventError(event)); 46 | break; 47 | } 48 | } 49 | } 50 | 51 | /// A stream transformer that converts a stream of [ServerSentEvent]s into a 52 | /// stream of [StreamEvent]s, decoding the event types used by firebase. 53 | /// 54 | /// **Note:** Typically, you would use [RestApi.stream] instead of using this 55 | /// class directly. 56 | /// 57 | /// If any events are received that are not known by this library, a 58 | /// [UnknownStreamEventError] is thrown. As this is an error, this should never 59 | /// happen, unless firebase decides to change how their APIs work. 60 | class StreamEventTransformer 61 | implements StreamTransformer { 62 | /// All the SSE event types that are consumed by this transformer. 63 | static const eventTypes = [ 64 | 'put', 65 | 'patch', 66 | 'keep-alive', 67 | 'cancel', 68 | 'auth_revoked', 69 | ]; 70 | 71 | /// Default constructor. 72 | const StreamEventTransformer(); 73 | 74 | @override 75 | Stream bind(Stream stream) => 76 | Stream.eventTransformed( 77 | stream, 78 | (sink) => StreamEventTransformerSink(sink), 79 | ); 80 | 81 | @override 82 | StreamTransformer cast() => 83 | StreamTransformer.castFrom(this); 84 | } 85 | -------------------------------------------------------------------------------- /test/unit/database/store_helpers/callback_store_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_database_rest/src/database/store_helpers/callback_store.dart'; 2 | import 'package:firebase_database_rest/src/rest/rest_api.dart'; 3 | import 'package:mocktail/mocktail.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | abstract class Callable1 { 7 | dynamic call(dynamic a1); 8 | } 9 | 10 | abstract class Callable2 { 11 | dynamic call(dynamic a1, dynamic a2); 12 | } 13 | 14 | class FakeRestApi extends Fake implements RestApi {} 15 | 16 | class MockCallable1 extends Mock implements Callable1 {} 17 | 18 | class MockCallable2 extends Mock implements Callable2 {} 19 | 20 | void main() { 21 | final fakeRestApi = FakeRestApi(); 22 | final mockDataFromJson = MockCallable1(); 23 | final mockDataToJson = MockCallable1(); 24 | final mockPatchData = MockCallable2(); 25 | 26 | late CallbackFirebaseStore sut; 27 | 28 | setUp(() { 29 | reset(mockDataFromJson); 30 | reset(mockDataToJson); 31 | reset(mockPatchData); 32 | 33 | sut = CallbackFirebaseStore.api( 34 | restApi: fakeRestApi, 35 | subPaths: const ['a', 'b'], 36 | onDataFromJson: mockDataFromJson, 37 | onDataToJson: mockDataToJson, 38 | onPatchData: mockPatchData, 39 | ); 40 | }); 41 | 42 | test('passes restApi and subPaths to super', () { 43 | expect(sut.restApi, fakeRestApi); 44 | expect(sut.subPaths, const ['a', 'b']); 45 | }); 46 | 47 | test('passes parent and path to super', () { 48 | final sut2 = CallbackFirebaseStore( 49 | parent: sut, 50 | path: 'c', 51 | onDataFromJson: mockDataFromJson, 52 | onDataToJson: mockDataToJson, 53 | onPatchData: mockPatchData, 54 | ); 55 | 56 | expect(sut2.restApi, fakeRestApi); 57 | expect(sut2.subPaths, const ['a', 'b', 'c']); 58 | }); 59 | 60 | test('dataFromJson calls callback', () async { 61 | when(() => mockDataFromJson.call(any())).thenReturn(42); 62 | 63 | final dynamic res = sut.dataFromJson('42'); 64 | 65 | expect(res, 42); 66 | verify(() => mockDataFromJson.call('42')); 67 | }); 68 | 69 | test('dataToJson calls callback', () async { 70 | when(() => mockDataToJson.call(any())).thenReturn(42); 71 | 72 | final dynamic res = sut.dataToJson('42'); 73 | 74 | expect(res, 42); 75 | verify(() => mockDataToJson.call('42')); 76 | }); 77 | 78 | test('patchData calls callback', () async { 79 | when(() => mockPatchData.call( 80 | any(), 81 | any(), 82 | )).thenReturn(42); 83 | 84 | final dynamic res = sut.patchData('42', {'a': true}); 85 | 86 | expect(res, 42); 87 | verify( 88 | () => mockPatchData.call('42', {'a': true})); 89 | }); 90 | } 91 | -------------------------------------------------------------------------------- /lib/src/common/timeout.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'timeout.freezed.dart'; 4 | 5 | /// Specifies timeout for read requests. 6 | /// 7 | /// Use this to limit how long the read takes on the server side. If a read 8 | /// request doesn't finish within the allotted time, it terminates with an HTTP 9 | /// 400 error. This is particularly useful when you expect a small data transfer 10 | /// and don't want to wait too long to fetch a potentially huge subtree. Actual 11 | /// read time might vary based on data size and caching. 12 | /// 13 | /// **Note:** The maximum timeout is 15 minutes. 14 | @freezed 15 | class Timeout with _$Timeout { 16 | const Timeout._(); 17 | 18 | /// Creates a timeout with a milliseconds resolution for [value] 19 | @Assert('value > 0', 'value must be a positive integer') 20 | @Assert('value <= 900000', 'value must be at most 15 min (900000 ms)') 21 | const factory Timeout.ms(int value) = _TimeoutMs; 22 | 23 | /// Creates a timeout with a seconds resolution for [value] 24 | @Assert('value > 0', 'value must be a positive integer') 25 | @Assert('value <= 900', 'value must be at most 15 min (900 s)') 26 | const factory Timeout.s(int value) = _TimeoutS; 27 | 28 | /// Creates a timeout with a minutes resolution for [value] 29 | @Assert('value > 0', 'value must be a positive integer') 30 | @Assert('value <= 15', 'value must be at most 15 min') 31 | const factory Timeout.min(int value) = _TimeoutMin; 32 | 33 | /// The integer value of the timeout. 34 | /// 35 | /// Depending on the timeout, this can either be milliseconds, seconds or 36 | /// minutes. 37 | @override 38 | int get value; 39 | 40 | /// Creates a timeout from a [Duration] object. 41 | /// 42 | /// The limit of max. 15 minutes still applies for [duration]. The resulting 43 | /// timeout will be either ms, s or min, depending on whether the [duration] 44 | /// fits into each without a remainder. For durations with parts smaller then 45 | /// milliseconds, those parts get ignored. 46 | factory Timeout.fromDuration(Duration duration) { 47 | if (duration.inMilliseconds % 1000 != 0) { 48 | return Timeout.ms(duration.inMilliseconds); 49 | } else if (duration.inSeconds % 60 != 0) { 50 | return Timeout.s(duration.inSeconds); 51 | } else { 52 | return Timeout.min(duration.inMinutes); 53 | } 54 | } 55 | 56 | /// Converts the timeout to a [Duration] with the same time value. 57 | Duration get duration => when( 58 | ms: (value) => Duration(milliseconds: value), 59 | s: (value) => Duration(seconds: value), 60 | min: (value) => Duration(minutes: value), 61 | ); 62 | 63 | @override 64 | String toString() => when( 65 | ms: (value) => '${value}ms', 66 | s: (value) => '${value}s', 67 | min: (value) => '${value}min', 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /lib/src/stream/sse_client.dart: -------------------------------------------------------------------------------- 1 | import 'package:http/http.dart'; 2 | 3 | import 'server_sent_event.dart'; 4 | 5 | import 'sse_client_factory.dart' 6 | if (dart.library.io) 'vm/sse_client_vm_factory.dart' 7 | if (dart.library.html) 'js/sse_client_js_factory.dart'; 8 | 9 | /// An exception that gets emitted by the [SSEStream] on connection problems. 10 | class SSEException implements Exception {} 11 | 12 | /// A specialized [Stream] that allows event registration. 13 | /// 14 | /// You can obtain an instance of such a stream via [SSEClient.stream]. After 15 | /// obtaining it, you should immediatly start to listen on the stream, to 16 | /// prevent data loss on some platforms. 17 | /// 18 | /// By default, the stream will not emit any events. You have to explicitly 19 | /// register each event type you want to receive via [addEventType]. You can 20 | /// remove them again via [removeEventType]. It does not matter, if you call 21 | /// these methods before or after listening to the stream. 22 | abstract class SSEStream extends Stream { 23 | /// Adds the given [event] type to the stream for listening. 24 | /// 25 | /// After adding the [event], all further instances of that event that are 26 | /// sent by the server will be emitted by this stream. You can use 27 | /// [ServerSentEvent.event] to find out which data belongs to which event type 28 | /// when processing them. 29 | /// 30 | /// Calling this function multiple times for the same [event] does nothing. 31 | /// You can listen to multiple different [event]s at the same time. 32 | /// 33 | /// To remove an added event, you can use [removeEventType]. 34 | void addEventType(String event); 35 | 36 | /// Removes the given [event] type from the stream for listening. 37 | /// 38 | /// This operation undos [addEventType] for the given [event], meaning that 39 | /// events of this type will not be received from the server anymore. The 40 | /// return value indicates if an event listener was acutally removed, or if 41 | /// there was none to begin with. 42 | bool removeEventType(String event); 43 | } 44 | 45 | /// A specialized [Client] that allows streaming server sent events. 46 | abstract class SSEClient implements Client { 47 | /// Default constructor 48 | factory SSEClient() => SSEClientFactory.create(Client()); 49 | 50 | /// Creates an [SSEClient] that uses [client] for all operations but [stream]. 51 | factory SSEClient.proxy(Client client) => SSEClientFactory.create(client); 52 | 53 | /// Creates a stream of [ServerSentEvent]s from the given [url]. 54 | /// 55 | /// Internally, this will connect to the server and start receiving events. 56 | /// You should call [SSEStream.listen] immediatly on the returned stream to 57 | /// prevent data loss. 58 | /// 59 | /// The stream is a single subscription stream and controls the connection to 60 | /// the server. To close it, simply cancel the subscription on the stream. 61 | Future stream(Uri url); 62 | } 63 | -------------------------------------------------------------------------------- /lib/src/database/store_helpers/store_value_event_transformer.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:meta/meta.dart'; 4 | 5 | import '../../common/transformer_sink.dart'; 6 | import '../../rest/models/stream_event.dart'; 7 | import '../auth_revoked_exception.dart'; 8 | import '../store.dart'; 9 | import '../store_event.dart'; 10 | 11 | /// @nodoc 12 | @internal 13 | class StoreValueEventTransformerSink 14 | extends TransformerSink> { 15 | /// @nodoc 16 | final DataFromJsonCallback dataFromJson; 17 | 18 | /// @nodoc 19 | final PatchSetFactory patchSetFactory; 20 | 21 | /// @nodoc 22 | StoreValueEventTransformerSink({ 23 | required EventSink> outSink, 24 | required this.dataFromJson, 25 | required this.patchSetFactory, 26 | }) : super(outSink); 27 | 28 | @override 29 | void add(StreamEvent event) => event.when( 30 | put: _put, 31 | patch: _patch, 32 | authRevoked: _authRevoked, 33 | ); 34 | 35 | void _put(String path, dynamic data) { 36 | if (path == '/') { 37 | if (data == null) { 38 | outSink.add(const ValueEvent.delete()); 39 | } else { 40 | outSink.add(ValueEvent.update(dataFromJson(data))); 41 | } 42 | } else { 43 | outSink.add(ValueEvent.invalidPath(path)); 44 | } 45 | } 46 | 47 | void _patch(String path, dynamic data) { 48 | if (path == '/') { 49 | final patch = patchSetFactory(data as Map); 50 | outSink.add(ValueEvent.patch(patch)); 51 | } else { 52 | outSink.add(ValueEvent.invalidPath(path)); 53 | } 54 | } 55 | 56 | void _authRevoked() => addError(AuthRevokedException()); 57 | } 58 | 59 | /// A stream transformer that converts a stream of [StreamEvent]s into a 60 | /// stream of [ValueEvent]s, deserializing the received data and turing database 61 | /// status updates into data updates. 62 | /// 63 | /// **Note:** Typically, you would use [FirebaseStore.streamEntry] instead of 64 | /// using this class directly. 65 | class StoreValueEventTransformer 66 | implements StreamTransformer> { 67 | /// A callback that can convert the received JSON data to [T] 68 | final DataFromJsonCallback dataFromJson; 69 | 70 | /// A callback that can generate [PatchSet] instances for patch events 71 | final PatchSetFactory patchSetFactory; 72 | 73 | /// Default constructor 74 | const StoreValueEventTransformer({ 75 | required this.dataFromJson, 76 | required this.patchSetFactory, 77 | }); 78 | 79 | @override 80 | Stream> bind(Stream stream) => 81 | Stream.eventTransformed( 82 | stream, 83 | (sink) => StoreValueEventTransformerSink( 84 | outSink: sink, 85 | dataFromJson: dataFromJson, 86 | patchSetFactory: patchSetFactory, 87 | ), 88 | ); 89 | 90 | @override 91 | StreamTransformer cast() => 92 | StreamTransformer.castFrom, RS, RT>(this); 93 | } 94 | -------------------------------------------------------------------------------- /test/unit/database/transaction_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: invalid_use_of_protected_member 2 | 3 | import 'package:firebase_database_rest/src/database/transaction.dart'; 4 | import 'package:mocktail/mocktail.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | class MockHelper extends Mock implements FirebaseTransaction {} 8 | 9 | class TestTransaction extends SingleCommitTransaction { 10 | final mock = MockHelper(); 11 | 12 | @override 13 | String get eTag => mock.eTag; 14 | 15 | @override 16 | String get key => mock.key; 17 | 18 | @override 19 | int? get value => mock.value; 20 | 21 | @override 22 | Future commitDeleteImpl() => mock.commitDelete(); 23 | 24 | @override 25 | Future commitUpdateImpl(int? data) => mock.commitUpdate(data); 26 | } 27 | 28 | void main() { 29 | group('SingleCommitTransaction', () { 30 | late TestTransaction sut; 31 | 32 | setUp(() { 33 | sut = TestTransaction(); 34 | }); 35 | 36 | test('does not affect properties', () { 37 | when(() => sut.mock.eTag).thenReturn('eTag'); 38 | when(() => sut.mock.key).thenReturn('key'); 39 | when(() => sut.mock.value).thenReturn(42); 40 | 41 | expect(sut.eTag, 'eTag'); 42 | expect(sut.key, 'key'); 43 | expect(sut.value, 42); 44 | }); 45 | 46 | group('allow only once commit', () { 47 | setUp(() { 48 | when(() => sut.mock.commitDelete()).thenAnswer((i) async {}); 49 | when(() => sut.mock.commitUpdate(any())).thenAnswer((i) async => 42); 50 | }); 51 | 52 | test('update, update', () async { 53 | final res = await sut.commitUpdate(13); 54 | expect(res, 42); 55 | verify(() => sut.mock.commitUpdate(13)); 56 | 57 | expect( 58 | () => sut.commitUpdate(31), 59 | throwsA(isA()), 60 | ); 61 | verifyNoMoreInteractions(sut.mock); 62 | }); 63 | 64 | test('update, delete', () async { 65 | final res = await sut.commitUpdate(13); 66 | expect(res, 42); 67 | verify(() => sut.mock.commitUpdate(13)); 68 | 69 | expect( 70 | () => sut.commitDelete(), 71 | throwsA(isA()), 72 | ); 73 | verifyNoMoreInteractions(sut.mock); 74 | }); 75 | 76 | test('delete, update', () async { 77 | await sut.commitDelete(); 78 | verify(() => sut.mock.commitDelete()); 79 | 80 | expect( 81 | () => sut.commitUpdate(31), 82 | throwsA(isA()), 83 | ); 84 | verifyNoMoreInteractions(sut.mock); 85 | }); 86 | 87 | test('delete, delete', () async { 88 | await sut.commitDelete(); 89 | verify(() => sut.mock.commitDelete()); 90 | 91 | expect( 92 | () => sut.commitDelete(), 93 | throwsA(isA()), 94 | ); 95 | verifyNoMoreInteractions(sut.mock); 96 | }); 97 | }); 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /lib/src/stream/vm/event_stream_decoder.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:meta/meta.dart'; 4 | 5 | import '../../common/transformer_sink.dart'; 6 | 7 | import '../server_sent_event.dart'; 8 | 9 | /// @nodoc 10 | @internal 11 | class EventStreamDecoderSink extends TransformerSink { 12 | String? _eventType; 13 | String? _lastEventId; 14 | final _data = []; 15 | 16 | /// @nodoc 17 | EventStreamDecoderSink(EventSink outSink) : super(outSink); 18 | 19 | @override 20 | void add(String event) { 21 | if (event.isEmpty) { 22 | if (_data.isNotEmpty) { 23 | outSink.add(ServerSentEvent( 24 | event: _eventType ?? 'message', 25 | data: _data.join('\n'), 26 | lastEventId: _lastEventId, 27 | )); 28 | } 29 | _eventType = null; 30 | _data.clear(); 31 | } else if (event.startsWith(':')) { 32 | return; 33 | } else { 34 | final colonIndex = event.indexOf(':'); 35 | var field = ''; 36 | var value = ''; 37 | if (colonIndex != -1) { 38 | field = event.substring(0, colonIndex); 39 | value = event.substring(colonIndex + 1); 40 | if (value.startsWith(' ')) { 41 | value = value.substring(1); 42 | } 43 | } else { 44 | field = event; 45 | } 46 | switch (field) { 47 | case 'event': 48 | _eventType = value.isNotEmpty ? value : null; 49 | break; 50 | case 'data': 51 | _data.add(value); 52 | break; 53 | case 'id': 54 | _lastEventId = value.isNotEmpty ? value : null; 55 | break; 56 | // case 'retry': Not implemented 57 | default: 58 | break; 59 | } 60 | } 61 | } 62 | } 63 | 64 | /// A stream transformer that converts a string stream into a stream of 65 | /// [ServerSentEvent]s 66 | /// 67 | /// **Note:** Typically, you would use [EventSource] instead of using this 68 | /// class directly. 69 | /// 70 | /// Expects each string to represent one line. This means any input stream must 71 | /// first be split into lines before beeing passed to this transformer. It will 72 | /// consume multiple lines to generate SSE events from the data. 73 | /// 74 | /// A typical usage would be to use for example the [StreamedResponse] from the 75 | /// `http` package and transform it in multiple steps like this: 76 | /// ```.dart 77 | /// StreamedResponse response = // ... 78 | /// final sseStream = response.stream 79 | /// .transform(utf8.decoder) 80 | /// .transform(const LineSplitter()) 81 | /// .transform(const EventStreamDecoder()); 82 | /// ``` 83 | class EventStreamDecoder implements StreamTransformer { 84 | /// Default constructor 85 | const EventStreamDecoder(); 86 | 87 | @override 88 | Stream bind(Stream stream) => 89 | Stream.eventTransformed( 90 | stream, 91 | (sink) => EventStreamDecoderSink(sink), 92 | ); 93 | 94 | @override 95 | StreamTransformer cast() => 96 | StreamTransformer.castFrom(this); 97 | } 98 | -------------------------------------------------------------------------------- /example/firebase_database_rest_example.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print 2 | import 'dart:async'; 3 | import 'dart:io'; 4 | 5 | import 'package:firebase_auth_rest/firebase_auth_rest.dart'; 6 | import 'package:firebase_database_rest/firebase_database_rest.dart'; 7 | import 'package:http/http.dart'; 8 | 9 | class ExampleModel { 10 | final int id; 11 | final String data; 12 | 13 | const ExampleModel(this.id, this.data); 14 | 15 | @override 16 | bool operator ==(covariant ExampleModel other) => 17 | id == other.id && data == other.data; 18 | 19 | @override 20 | int get hashCode => runtimeType.hashCode ^ id.hashCode ^ data.hashCode; 21 | 22 | @override 23 | String toString() => 'ExampleModel(id: $id, data: $data)'; 24 | } 25 | 26 | // pass your firebase project id as first argument and the API key as second 27 | Future main(List args) async { 28 | if (args.length != 2) { 29 | print('First argument must be the firebase project id.'); 30 | print('Second argument must be the firebase API key.'); 31 | exit(-1); 32 | } 33 | 34 | Client? client; 35 | FirebaseAccount? account; 36 | FirebaseDatabase? database; 37 | StreamSubscription>? sub; 38 | try { 39 | // use firebase_auth_rest to log into a firebase account 40 | client = Client(); 41 | final auth = FirebaseAuth(client, args[1]); 42 | account = await auth.signUpAnonymous(autoRefresh: false); 43 | 44 | // create a database reference from that account 45 | database = FirebaseDatabase( 46 | account: account, 47 | database: args[0], 48 | basePath: 'firebase_database_rest/${account.localId}/demo', 49 | ); 50 | 51 | // create typed store. In this example, we use the database root path, 52 | // but it can also have a subpath 53 | // you should use json_serializable for json conversions in real projects 54 | final store = database.createRootStore( 55 | onDataFromJson: (dynamic json) => ExampleModel( 56 | json['id'] as int, 57 | json['data'] as String, 58 | ), 59 | onDataToJson: (data) => { 60 | 'id': data.id, 61 | 'data': data.data, 62 | }, 63 | onPatchData: (_, __) => throw UnimplementedError(), 64 | ); 65 | 66 | // get all keys -> initially empty 67 | print('Initial keys: ${await store.keys()}'); 68 | 69 | // add some data 70 | await store.create(const ExampleModel(1, 'A')); 71 | await store.write('myId', const ExampleModel(2, 'B')); 72 | 73 | // get all data in store 74 | print('All data: ${await store.all()}'); 75 | 76 | // query only some elements 77 | final filter = Filter.property('id').equalTo(2).build(); 78 | print('Only with id 2: ${await store.query(filter)}'); 79 | 80 | // stream changes (edit, delete) 81 | sub = (await store.streamAll()).listen((e) => print('Stream update: $e')); 82 | await store.write('myId', const ExampleModel(3, 'C')); 83 | await store.delete('myId'); 84 | await Future.delayed(const Duration(seconds: 1)); 85 | await sub.cancel(); 86 | sub = null; 87 | 88 | // cleanup: delete database and account 89 | await database.rootStore.destroy(); 90 | await account.delete(); 91 | } finally { 92 | await sub?.cancel(); 93 | await database?.dispose(); 94 | account?.dispose(); 95 | client?.close(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /lib/src/database/transaction.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import '../../firebase_database_rest.dart'; 4 | 5 | /// Error that gets thrown if a [SingleCommitTransaction] has already been 6 | /// committed. 7 | class AlreadyComittedError extends StateError { 8 | /// Default constructor. 9 | AlreadyComittedError() : super('Transaction has already been committed'); 10 | } 11 | 12 | /// Exception that gets thrown if the server rejects the transaction because 13 | /// the eTag has been modified. 14 | /// 15 | /// This is simply thrown in place of a 16 | /// [DbException] to make transaction errors easier to detect. 17 | class TransactionFailedException implements Exception { 18 | /// Default constructor 19 | const TransactionFailedException(); 20 | 21 | @override 22 | String toString() => 'Transaction failed - ' 23 | 'Database entry was modified since the transaction was started.'; 24 | } 25 | 26 | /// An interface for transactions on the realtime database 27 | abstract class FirebaseTransaction { 28 | /// The key of the entry beeing modified in the transaction 29 | String get key; 30 | 31 | /// The current value of the entry. 32 | /// 33 | /// If [value] is `null`, it means the entry does not exist yet. 34 | T? get value; 35 | 36 | /// The current eTag of the entry. 37 | /// 38 | /// Used internally to handle the transaction. 39 | String get eTag; 40 | 41 | /// Tries to commit an update of the entry to [data]. 42 | /// 43 | /// If it has not been modified since the transaction was started, this will 44 | /// succeed and return the updated data. If it was modified, a 45 | /// [TransactionFailedException] will be thrown instead. 46 | Future commitUpdate(T data); 47 | 48 | /// Tries to commit the deletion of the entry. 49 | /// 50 | /// If it has not been modified since the transaction was started, this will 51 | /// succeedta. If it was modified, a [TransactionFailedException] will be 52 | /// thrown instead. 53 | Future commitDelete(); 54 | } 55 | 56 | /// A helper class to easily create single commit transaction. 57 | /// 58 | /// Single commit transactions are transaction that can only be commit once 59 | /// and become invalid after. This class helps in that it handels this case 60 | /// and automatically throws a [AlreadyComittedError] if someone tries to commit 61 | /// it twice. 62 | /// 63 | /// Instead of [commitUpdate] and [commitDelete], you have to implement 64 | /// [commitUpdateImpl] and [commitDeleteImpl]. They follow the same semantics 65 | /// as their originals, beeing called by them after it was verified the 66 | /// transaction was not committed yet. 67 | abstract class SingleCommitTransaction implements FirebaseTransaction { 68 | bool _committed = false; 69 | 70 | /// See [commitUpdate] 71 | @protected 72 | Future commitUpdateImpl(T data); 73 | 74 | /// See [commitDelete] 75 | @protected 76 | Future commitDeleteImpl(); 77 | 78 | @nonVirtual 79 | @override 80 | Future commitUpdate(T data) { 81 | _assertNotCommitted(); 82 | return commitUpdateImpl(data); 83 | } 84 | 85 | @nonVirtual 86 | @override 87 | Future commitDelete() { 88 | _assertNotCommitted(); 89 | return commitDeleteImpl(); 90 | } 91 | 92 | void _assertNotCommitted() { 93 | if (_committed) { 94 | throw AlreadyComittedError(); 95 | } else { 96 | _committed = true; 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/src/database/store_helpers/store_event_transformer.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:meta/meta.dart'; 4 | 5 | import '../../common/transformer_sink.dart'; 6 | import '../../rest/models/stream_event.dart'; 7 | import '../auth_revoked_exception.dart'; 8 | import '../store.dart'; 9 | import '../store_event.dart'; 10 | import 'map_transform.dart'; 11 | 12 | /// @nodoc 13 | @internal 14 | class StoreEventTransformerSink 15 | extends TransformerSink> with MapTransform { 16 | static final _subPathRegexp = RegExp(r'^\/([^\/]+)$'); 17 | 18 | /// @nodoc 19 | final DataFromJsonCallback dataFromJson; 20 | 21 | /// @nodoc 22 | final PatchSetFactory patchSetFactory; 23 | 24 | /// @nodoc 25 | StoreEventTransformerSink({ 26 | required EventSink> outSink, 27 | required this.dataFromJson, 28 | required this.patchSetFactory, 29 | }) : super(outSink); 30 | 31 | @override 32 | void add(StreamEvent event) => event.when( 33 | put: _put, 34 | patch: _patch, 35 | authRevoked: _authRevoked, 36 | ); 37 | 38 | void _put(String path, dynamic data) { 39 | if (path == '/') { 40 | _reset(data); 41 | } else { 42 | _update(path, data); 43 | } 44 | } 45 | 46 | void _reset(dynamic data) => outSink.add( 47 | StoreEvent.reset(mapTransform(data, dataFromJson)), 48 | ); 49 | 50 | void _update(String path, dynamic data) { 51 | final match = _subPathRegexp.firstMatch(path); 52 | if (match != null) { 53 | if (data != null) { 54 | outSink.add(StoreEvent.put(match[1]!, dataFromJson(data))); 55 | } else { 56 | outSink.add(StoreEvent.delete(match[1]!)); 57 | } 58 | } else { 59 | outSink.add(StoreEvent.invalidPath(path)); 60 | } 61 | } 62 | 63 | void _patch(String path, dynamic data) { 64 | final match = _subPathRegexp.firstMatch(path); 65 | if (match != null) { 66 | outSink.add(StoreEvent.patch( 67 | match[1]!, 68 | patchSetFactory( 69 | data as Map? ?? const {}, 70 | ), 71 | )); 72 | } else { 73 | outSink.add(StoreEvent.invalidPath(path)); 74 | } 75 | } 76 | 77 | void _authRevoked() => addError(AuthRevokedException()); 78 | } 79 | 80 | /// A stream transformer that converts a stream of [StreamEvent]s into a 81 | /// stream of [StoreEvent]s, deserializing the received data and turing database 82 | /// status updates into data updates. 83 | /// 84 | /// **Note:** Typically, you would use [FirebaseStore.streamAll] instead of 85 | /// using this class directly. 86 | class StoreEventTransformer 87 | implements StreamTransformer> { 88 | /// A callback that can convert the received JSON data to [T] 89 | final DataFromJsonCallback dataFromJson; 90 | 91 | /// A callback that can generate [PatchSet] instances for patch events 92 | final PatchSetFactory patchSetFactory; 93 | 94 | /// Default constructor 95 | const StoreEventTransformer({ 96 | required this.dataFromJson, 97 | required this.patchSetFactory, 98 | }); 99 | 100 | @override 101 | Stream> bind(Stream stream) => 102 | Stream.eventTransformed( 103 | stream, 104 | (sink) => StoreEventTransformerSink( 105 | outSink: sink, 106 | dataFromJson: dataFromJson, 107 | patchSetFactory: patchSetFactory, 108 | ), 109 | ); 110 | 111 | @override 112 | StreamTransformer cast() => 113 | StreamTransformer.castFrom, RS, RT>(this); 114 | } 115 | -------------------------------------------------------------------------------- /lib/src/database/auto_renew_stream.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'auth_revoked_exception.dart'; 4 | 5 | /// Method signature of a stream factory method. 6 | typedef StreamFactory = Future> Function(); 7 | 8 | /// A wrapper around streams that throw [AuthRevokedException]s to automatically 9 | /// reconnect and continue the stream seamlessly. 10 | /// 11 | /// This works by providing a [streamFactory], which is used to generate new 12 | /// streams. The stream events are simply forwarded to the consumer of this 13 | /// stream. If the original stream throws an [AuthRevokedException], instead of 14 | /// forwarding, the original stream gets canceled and [streamFactory] is used to 15 | /// create a new stream, which is then forwarded. 16 | /// 17 | /// **Note:** This only recreates streams for that specific exceptions. All 18 | /// other errors are simply forwarded. If the original stream gets closed or 19 | /// canceled, so does this stream. 20 | class AutoRenewStream extends Stream { 21 | /// The stream factory that is used to generate new streams. 22 | final StreamFactory streamFactory; 23 | 24 | final Stream? _initialStream; 25 | late final _controller = StreamController( 26 | onListen: _onListen, 27 | onCancel: _onCancel, 28 | onPause: _onPause, 29 | onResume: _onResume, 30 | ); 31 | 32 | StreamSubscription? _currentSub; 33 | 34 | /// Default constructor 35 | /// 36 | /// Constructs a stream from a [streamFactory]. The first time someone calls 37 | /// [listen] on this stream, the factory is used to create the initial stream, 38 | /// which is then consumed as usual. 39 | AutoRenewStream(this.streamFactory) : _initialStream = null; 40 | 41 | /// Existing stream constructor. 42 | /// 43 | /// In case you already have an [initialStream], you can use this constructor. 44 | /// It will use the [initialStream] as soon as [listen] is is called. Only 45 | /// when this initial stream throws an [AuthRevokedException], the 46 | /// [streamFactory] is used to create a new one. 47 | AutoRenewStream.fromStream(Stream initialStream, this.streamFactory) 48 | : _initialStream = initialStream; 49 | 50 | @override 51 | StreamSubscription listen( 52 | void Function(T event)? onData, { 53 | Function? onError, 54 | void Function()? onDone, 55 | bool? cancelOnError, 56 | }) => 57 | _controller.stream.listen( 58 | onData, 59 | onError: onError, 60 | onDone: onDone, 61 | cancelOnError: cancelOnError, 62 | ); 63 | 64 | Future _onListen() async => 65 | _listenNext(_initialStream ?? await streamFactory()); 66 | 67 | Future _onCancel() async { 68 | await _currentSub?.cancel(); 69 | if (!_controller.isClosed) { 70 | await _controller.close(); 71 | } 72 | } 73 | 74 | void _onPause() => _currentSub?.pause(); 75 | 76 | void _onResume() => _currentSub?.resume(); 77 | 78 | void _listenNext(Stream stream) { 79 | if (_controller.isClosed) { 80 | return; 81 | } 82 | 83 | _currentSub = stream.listen( 84 | _controller.add, 85 | onDone: _controller.close, 86 | onError: _handleError, 87 | cancelOnError: false, 88 | ); 89 | 90 | if (_controller.isPaused) { 91 | _currentSub!.pause(); 92 | } 93 | } 94 | 95 | Future _handleError(Object error, StackTrace stackTrace) async { 96 | if (error is! AuthRevokedException) { 97 | _controller.addError(error, stackTrace); 98 | return; 99 | } 100 | 101 | final sub = _currentSub; 102 | _currentSub = null; 103 | await sub?.cancel(); 104 | _listenNext(await streamFactory()); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /test/stream_matcher_queue.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:collection'; 3 | 4 | import 'package:test/test.dart'; 5 | import 'package:test_api/src/expect/async_matcher.dart'; 6 | 7 | enum _MatchType { 8 | data, 9 | error, 10 | done, 11 | } 12 | 13 | class _MatchData { 14 | final _MatchType _type; 15 | final dynamic _raw; 16 | 17 | _MatchData.data(T data) 18 | : _type = _MatchType.data, 19 | _raw = data; 20 | 21 | _MatchData.error(Object error) 22 | : _type = _MatchType.error, 23 | _raw = error; 24 | 25 | _MatchData.done() 26 | : _type = _MatchType.done, 27 | _raw = null; 28 | 29 | T? get value { 30 | switch (_type) { 31 | case _MatchType.data: 32 | return _raw as T; 33 | case _MatchType.error: 34 | // ignore: only_throw_errors 35 | throw _raw as Object; 36 | case _MatchType.done: 37 | return null; 38 | } 39 | } 40 | 41 | @override 42 | String toString() => _raw?.toString() ?? '#done'; 43 | } 44 | 45 | class StreamMatcherQueue { 46 | late final StreamSubscription _sub; 47 | 48 | final _events = Queue<_MatchData>(); 49 | _MatchData? _lastMatch; 50 | 51 | StreamMatcherQueue(Stream stream) { 52 | _sub = stream.listen( 53 | _onData, 54 | onError: _onError, 55 | onDone: _onDone, 56 | cancelOnError: false, 57 | ); 58 | } 59 | 60 | bool get isEmpty => _events.isEmpty; 61 | 62 | bool get isNotEmpty => _events.isNotEmpty; 63 | 64 | Iterable<_MatchData> get events => _events; 65 | 66 | StreamSubscription get sub => _sub; 67 | 68 | Future next({bool pop = true}) async { 69 | while (isEmpty) { 70 | await Future.delayed(const Duration(milliseconds: 10)); 71 | } 72 | _lastMatch = pop ? _events.removeFirst() : _events.first; 73 | return _lastMatch!.value; 74 | } 75 | 76 | void dropDescribe() => _lastMatch = null; 77 | 78 | void _onData(T data) => _events.add(_MatchData.data(data)); 79 | 80 | void _onError(Object error) => _events.add(_MatchData.error(error)); 81 | 82 | void _onDone() => _events.add(_MatchData.done()); 83 | 84 | Future close() => _sub.cancel(); 85 | 86 | @override 87 | String toString() => _lastMatch?.toString() ?? _events.toString(); 88 | } 89 | 90 | class _QueueMatcher extends AsyncMatcher { 91 | final List matchers; 92 | 93 | Matcher? _lastMatcher; 94 | 95 | _QueueMatcher(this.matchers); 96 | 97 | @override 98 | Description describe(Description description) => 99 | description.addDescriptionOf(_lastMatcher); 100 | 101 | @override 102 | FutureOr matchAsync(covariant StreamMatcherQueue item) async { 103 | for (final matcher in matchers) { 104 | if (matcher is _ErrorMatcher) { 105 | _lastMatcher = throwsA(matcher.matcher); 106 | if (!_lastMatcher!.matches(() => item.next(), {})) { 107 | return 'did not match. Remaining queue: ${item.events}'; 108 | } 109 | item.dropDescribe(); 110 | } else { 111 | _lastMatcher = matcher is Matcher ? matcher : equals(matcher); 112 | final dynamic event = await item.next(); 113 | if (!_lastMatcher!.matches(event, {})) { 114 | return 'did not match. Remaining queue: ${item.events}'; 115 | } 116 | item.dropDescribe(); 117 | } 118 | } 119 | return null; 120 | } 121 | } 122 | 123 | Matcher emitsQueued(dynamic matchers) => 124 | _QueueMatcher(matchers is List ? matchers : [matchers]); 125 | 126 | class _ErrorMatcher { 127 | final dynamic matcher; 128 | 129 | const _ErrorMatcher(this.matcher); 130 | } 131 | 132 | dynamic asError(dynamic matcher) => _ErrorMatcher(matcher); 133 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Continous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | test: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | platform: 17 | - linux 18 | - windows 19 | - macos 20 | - web 21 | 22 | include: 23 | - platform: linux 24 | os: ubuntu-latest 25 | system: vm 26 | allTestLimit: 500 27 | - platform: windows 28 | os: windows-latest 29 | system: vm 30 | allTestLimit: 500 31 | - platform: macos 32 | os: macos-latest 33 | system: vm 34 | allTestLimit: 500 35 | - platform: web 36 | os: ubuntu-latest 37 | system: js 38 | allTestLimit: 100 39 | 40 | runs-on: ${{ matrix.os }} 41 | steps: 42 | - uses: dart-lang/setup-dart@v1 43 | - uses: actions/checkout@v2 44 | - run: make get 45 | - run: make build 46 | - run: make analyze 47 | - run: make unit-tests-${{ matrix.system }}-coverage 48 | - run: make integration-tests-${{ matrix.system }} 49 | env: 50 | FIREBASE_ALL_TEST_LIMIT: ${{ matrix.allTestLimit }} 51 | FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} 52 | FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} 53 | - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 54 | with: 55 | min_coverage: 95 56 | exclude: | 57 | **/*.freezed.dart 58 | **/*.g.dart 59 | - run: make publish-dry 60 | 61 | release: 62 | runs-on: ubuntu-latest 63 | needs: 64 | - test 65 | if: github.ref == 'refs/heads/master' 66 | outputs: 67 | update: ${{ steps.version.outputs.update }} 68 | tag_name: ${{ steps.version.outputs.tag_name }} 69 | steps: 70 | - uses: dart-lang/setup-dart@v1 71 | - uses: actions/checkout@v2 72 | - uses: Skycoder42/action-dart-release@v1 73 | id: version 74 | - name: Create Release 75 | if: steps.version.outputs.update == 'true' 76 | uses: actions/create-release@v1 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | with: 80 | tag_name: v${{ steps.version.outputs.tag_name }} 81 | release_name: ${{ steps.version.outputs.release_name }} 82 | body_path: ${{ steps.version.outputs.body_path }} 83 | 84 | deploy: 85 | runs-on: ubuntu-latest 86 | needs: 87 | - release 88 | if: needs.release.outputs.update == 'true' 89 | steps: 90 | - uses: dart-lang/setup-dart@v1 91 | - uses: actions/checkout@v2 92 | - run: make get 93 | - run: make build 94 | - name: store credentials 95 | run: | 96 | mkdir -p ~/.pub-cache 97 | echo '${{ secrets.PUB_DEV_CREDENTIALS }}' > ~/.pub-cache/credentials.json 98 | - run: make publish 99 | - name: clean up credentials 100 | if: always() 101 | run: shred -fzvu ~/.pub-cache/credentials.json 102 | 103 | doc: 104 | runs-on: ubuntu-latest 105 | needs: 106 | - release 107 | if: needs.release.outputs.update == 'true' 108 | steps: 109 | - uses: dart-lang/setup-dart@v1 110 | - uses: actions/checkout@v2 111 | - run: make get 112 | - run: make build 113 | - run: make doc 114 | - name: upload doc 115 | uses: peaceiris/actions-gh-pages@v3 116 | with: 117 | github_token: ${{ secrets.GITHUB_TOKEN }} 118 | publish_dir: doc/api 119 | commit_message: Updated documentation to v${{ needs.release.outputs.tag_name }} 120 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # firebase_database_rest 2 | [![Continous Integration](https://github.com/Skycoder42/firebase_database_rest/actions/workflows/ci.yaml/badge.svg)](https://github.com/Skycoder42/firebase_database_rest/actions/workflows/ci.yaml) 3 | [![Pub Version](https://img.shields.io/pub/v/firebase_database_rest)](https://pub.dev/packages/firebase_database_rest) 4 | 5 | A platform independent Dart/Flutter Wrapper for the Firebase Realtime Database API based on REST 6 | 7 | ## Features 8 | - Pure Dart-based implementation 9 | - works on all platforms supported by dart 10 | - Uses the official REST-API endpoints 11 | - Provides high-level classes to manage database access 12 | - Supports Model-Binding via Generics and converter functions 13 | - Full support of all Realtime database features: 14 | - Supports CRUD 15 | - Supports Queries (filtering, sorting) 16 | - Supports realtime server updates (streaming) 17 | - Supports ETags 18 | - Provides a Transaction class to wrap ETag-Logic into safe transactions 19 | - Provides timestamp class for server set timestamps 20 | - Proivides low-level REST classes for direct API access (import `firebase_database_rest/rest.dart`) 21 | - Fully integrated with [firebase_auth_rest](https://github.com/Skycoder42/firebase_auth_rest) - including automatic auth token refreshes 22 | 23 | ## Installation 24 | Simply add `firebase_database_rest` to your `pubspec.yaml` and run `pub get` (or `flutter pub get`). 25 | 26 | ## Usage 27 | The libraries primary class is the `FirebaseStore`. It is a high-level class that allows access to a certain part of the realtime database using Model binding. 28 | With this store, you specify a subpatch and can then perform various operations on elements below that path (list key, create, read, write or delete them, ...). 29 | This class also provides the APIs for streaming changes and transactions. Typically, your application would use multiple stores, one for each dataclass you plan 30 | to store at a certain path. 31 | 32 | The second important class is the `FirebaseDatabase`. It serves as the toplevel class, parent to all stores and manages the database connection itself, including 33 | the management of the associated `FirebaseAccount` (from [firebase_auth_rest](https://github.com/Skycoder42/firebase_auth_rest)) and background ressources 34 | (like the REST-API interface). 35 | 36 | The following code is a simple example, which can be found in full length, including errorhandling, at https://pub.dev/packages/firebase_database_rest/example. It loggs into firebase as anonymous user, starts streaming changes and then does some db operations. 37 | 38 | ```.dart 39 | // The data class that is stored in firebase 40 | class ExampleModel { 41 | final int id; 42 | final String data; 43 | 44 | const ExampleModel(this.id, this.data); 45 | 46 | // ... 47 | } 48 | 49 | // obtain a firebase account, using firebase_auth_rest 50 | final account = // ... 51 | 52 | // create a database reference from that account 53 | final database = FirebaseDatabase( 54 | account: account, 55 | database: 'my-firebase-realtime-database-name, 56 | basePath: 'application/${account.localId}/example', 57 | ); 58 | 59 | // create typed store. In this example, a callback store is used, but you can 60 | // also just extend FirebaseStore 61 | final store = database.createRootStore( 62 | onDataFromJson: (dynamic json) => ExampleModel.fromJson(json), 63 | onDataToJson: (data) => data.toJson(), 64 | onPatchData: (data, updatedFields) => data.patch(updatedFields), 65 | ); 66 | 67 | // add a stream listener that reports database changes in realtime 68 | sub = (await store.streamAll()).listen((e) => print('Stream update: $e')); 69 | 70 | // add some data to the store 71 | await store.create(const ExampleModel(1, 'A')); 72 | 73 | // get all data in store 74 | print('All data: ${await store.all()}'); 75 | 76 | // cleanup: stop streaming and dispose of the database 77 | await sub.cancel(); 78 | await database.dispose(); 79 | ``` 80 | 81 | ## Documentation 82 | The documentation is available at https://pub.dev/documentation/firebase_database_rest/latest/. 83 | A full example can be found at https://pub.dev/packages/firebase_database_rest/example. 84 | -------------------------------------------------------------------------------- /lib/src/stream/js/sse_client_js.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:html'; 3 | 4 | import 'package:http/http.dart'; 5 | import 'package:meta/meta.dart'; 6 | 7 | import '../server_sent_event.dart'; 8 | import '../sse_client.dart'; 9 | import '../sse_client_factory.dart'; 10 | 11 | class _SSEExceptionJS implements SSEException { 12 | final Event _errorEvent; 13 | 14 | _SSEExceptionJS(this._errorEvent); 15 | 16 | @override 17 | String toString() { 18 | String? message; 19 | if (_errorEvent is ErrorEvent) { 20 | message = (_errorEvent as ErrorEvent).message; 21 | } 22 | return message ?? 'Unknown error'; 23 | } 24 | } 25 | 26 | class _SSEStreamControllerJS { 27 | final Uri url; 28 | final EventSource Function(Uri url) _eventSourceFactory; 29 | 30 | final _eventListeners = {}; 31 | late final EventSource _eventSource; 32 | late final _controller = StreamController( 33 | onListen: _onListen, 34 | onCancel: _onCancel, 35 | ); 36 | 37 | // coverage:ignore-start 38 | _SSEStreamControllerJS( 39 | this.url, 40 | this._eventSourceFactory, 41 | ); 42 | // coverage:ignore-end 43 | 44 | Stream get stream => _controller.stream; 45 | 46 | void addEventType(String event) { 47 | _eventListeners.putIfAbsent(event, () { 48 | void listener(Event eventData) => _handleEvent( 49 | event, 50 | eventData as MessageEvent, 51 | ); 52 | if (_controller.hasListener) { 53 | _eventSource.addEventListener(event, listener); 54 | } 55 | return listener; 56 | }); 57 | } 58 | 59 | bool removeEventType(String event) { 60 | final listener = _eventListeners.remove(event); 61 | if (listener != null) { 62 | if (_controller.hasListener) { 63 | _eventSource.removeEventListener(event, listener); 64 | } 65 | return true; 66 | } 67 | return false; 68 | } 69 | 70 | void _onListen() { 71 | _eventSource = _eventSourceFactory(url) 72 | ..addEventListener( 73 | 'error', 74 | (event) => _controller.addError(_SSEExceptionJS(event)), 75 | ); 76 | for (final entry in _eventListeners.entries) { 77 | _eventSource.addEventListener(entry.key, entry.value); 78 | } 79 | } 80 | 81 | void _onCancel() { 82 | _eventSource.close(); 83 | _controller.close(); 84 | } 85 | 86 | void _handleEvent(String event, MessageEvent eventData) { 87 | if (_controller.isPaused) { 88 | return; 89 | } 90 | 91 | _controller.add(ServerSentEvent( 92 | event: event, 93 | data: eventData.data as String, 94 | lastEventId: 95 | eventData.lastEventId == 'null' ? null : eventData.lastEventId, 96 | )); 97 | } 98 | } 99 | 100 | class _SSEStreamJS extends SSEStream { 101 | final _SSEStreamControllerJS _sseStreamController; 102 | 103 | _SSEStreamJS(this._sseStreamController); // coverage:ignore-line 104 | 105 | @override 106 | StreamSubscription listen( 107 | void Function(ServerSentEvent event)? onData, { 108 | Function? onError, 109 | void Function()? onDone, 110 | bool? cancelOnError, 111 | }) => 112 | _sseStreamController.stream.listen( 113 | onData, 114 | onError: onError, 115 | onDone: onDone, 116 | cancelOnError: cancelOnError, 117 | ); 118 | 119 | @override 120 | void addEventType(String event) => _sseStreamController.addEventType(event); 121 | 122 | @override 123 | bool removeEventType(String event) => 124 | _sseStreamController.removeEventType(event); 125 | } 126 | 127 | /// @nodoc 128 | @internal 129 | class SSEClientJS with ClientProxy implements SSEClient { 130 | @override 131 | final Client client; 132 | 133 | /// @nodoc 134 | const SSEClientJS(this.client); 135 | 136 | @override 137 | Future stream(Uri url) => Future.value( 138 | _SSEStreamJS( 139 | _SSEStreamControllerJS(url, createEventSource), 140 | ), 141 | ); 142 | 143 | /// @nodoc 144 | @protected 145 | EventSource createEventSource(Uri url) => EventSource(url.toString()); 146 | } 147 | -------------------------------------------------------------------------------- /test/unit/database/store_helpers/store_key_event_transformer_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:firebase_database_rest/src/database/auth_revoked_exception.dart'; 4 | import 'package:firebase_database_rest/src/database/store_event.dart'; 5 | import 'package:firebase_database_rest/src/database/store_helpers/store_key_event_transformer.dart'; 6 | import 'package:firebase_database_rest/src/rest/models/stream_event.dart'; 7 | import 'package:mocktail/mocktail.dart'; 8 | import 'package:test/test.dart'; 9 | import 'package:tuple/tuple.dart'; 10 | 11 | import '../../../test_data.dart'; 12 | 13 | class MockKeyEventSink extends Mock implements EventSink {} 14 | 15 | void main() { 16 | group('StoreKeyEventTransformerSink', () { 17 | final mockKeyEventSink = MockKeyEventSink(); 18 | 19 | late StoreKeyEventTransformerSink sut; 20 | 21 | setUp(() { 22 | reset(mockKeyEventSink); 23 | 24 | sut = StoreKeyEventTransformerSink(mockKeyEventSink); 25 | }); 26 | 27 | tearDown(() { 28 | sut.close(); 29 | }); 30 | 31 | group('add', () { 32 | testData>( 33 | 'maps events correctly', 34 | const [ 35 | Tuple2( 36 | StreamEvent.put( 37 | path: '/', 38 | data: { 39 | 'a': 1, 40 | 'b': 2, 41 | 'c': 3, 42 | 'd': null, 43 | }, 44 | ), 45 | KeyEvent.reset(['a', 'b', 'c']), 46 | ), 47 | Tuple2( 48 | StreamEvent.put( 49 | path: '/', 50 | data: null, 51 | ), 52 | KeyEvent.reset([]), 53 | ), 54 | Tuple2( 55 | StreamEvent.put( 56 | path: '/d', 57 | data: 4, 58 | ), 59 | KeyEvent.update('d'), 60 | ), 61 | Tuple2( 62 | StreamEvent.put( 63 | path: '/e', 64 | data: null, 65 | ), 66 | KeyEvent.delete('e'), 67 | ), 68 | Tuple2( 69 | StreamEvent.put( 70 | path: '/f/id', 71 | data: 6, 72 | ), 73 | KeyEvent.invalidPath('/f/id'), 74 | ), 75 | Tuple2( 76 | StreamEvent.patch( 77 | path: '/g', 78 | data: 7, 79 | ), 80 | KeyEvent.update('g'), 81 | ), 82 | Tuple2( 83 | StreamEvent.patch( 84 | path: '/h/data', 85 | data: 8, 86 | ), 87 | KeyEvent.invalidPath('/h/data'), 88 | ), 89 | ], 90 | (fixture) {}, 91 | ); 92 | 93 | test('maps auth revoked to error', () { 94 | sut.add(const StreamEvent.authRevoked()); 95 | verify( 96 | () => 97 | mockKeyEventSink.addError(any(that: isA())), 98 | ); 99 | verifyNoMoreInteractions(mockKeyEventSink); 100 | }); 101 | }); 102 | 103 | test('addError forwards addError event', () async { 104 | const error = 'error'; 105 | final trace = StackTrace.current; 106 | 107 | sut.addError(error, trace); 108 | 109 | verify(() => mockKeyEventSink.addError(error, trace)); 110 | }); 111 | 112 | test('close forwards close event', () { 113 | sut.close(); 114 | 115 | verify(() => mockKeyEventSink.close()); 116 | }); 117 | }); 118 | 119 | group('StoreEventTransformer', () { 120 | late StoreKeyEventTransformer sut; 121 | 122 | setUp(() { 123 | sut = const StoreKeyEventTransformer(); 124 | }); 125 | 126 | test('bind creates eventTransformed stream', () async { 127 | final boundStream = sut.bind(Stream.fromIterable(const [ 128 | StreamEvent.put( 129 | path: '/x', 130 | data: 42, 131 | ), 132 | ])); 133 | 134 | final res = await boundStream.single; 135 | 136 | expect(res, const KeyEvent.update('x')); 137 | }); 138 | 139 | test('cast returns transformed instance', () { 140 | final castTransformer = sut.cast(); 141 | expect(castTransformer, isNotNull); 142 | }); 143 | }); 144 | } 145 | -------------------------------------------------------------------------------- /test/unit/stream/vm/sse_client_test_vm.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:firebase_database_rest/src/stream/server_sent_event.dart'; 4 | import 'package:firebase_database_rest/src/stream/sse_client.dart'; 5 | import 'package:firebase_database_rest/src/stream/vm/sse_client_vm.dart'; 6 | import 'package:http/http.dart'; 7 | import 'package:mocktail/mocktail.dart'; 8 | import 'package:test/test.dart'; 9 | 10 | class MockResponse extends Mock implements StreamedResponse {} 11 | 12 | class MockClient extends Mock implements Client {} 13 | 14 | void setupTests() => group('SSEClientVM', () { 15 | final mockClient = MockClient(); 16 | final mockResponse = MockResponse(); 17 | 18 | late SSEClientVM sut; 19 | 20 | setUp(() { 21 | reset(mockClient); 22 | reset(mockResponse); 23 | 24 | when(() => mockResponse.statusCode).thenReturn(200); 25 | when(() => mockResponse.stream) 26 | .thenAnswer((i) => const ByteStream(Stream.empty())); 27 | when(() => mockClient.send(any())) 28 | .thenAnswer((i) async => mockResponse); 29 | 30 | sut = SSEClientVM(mockClient); 31 | }); 32 | 33 | test('default constructor creates SSEClientVM with http.Client', () { 34 | final client = SSEClient(); 35 | 36 | expect( 37 | client, 38 | isA().having( 39 | (c) => c.client, 40 | 'client', 41 | isNot(mockClient), 42 | ), 43 | ); 44 | }); 45 | 46 | test('proxy constructor creates SSEClientVM with given Client', () { 47 | final client = SSEClient.proxy(mockClient); 48 | 49 | expect( 50 | client, 51 | isA().having( 52 | (c) => c.client, 53 | 'client', 54 | mockClient, 55 | ), 56 | ); 57 | }); 58 | 59 | group('stream', () { 60 | final url = Uri.http('localhost', '/'); 61 | 62 | test('sends get request with SSE headers', () async { 63 | await sut.stream(url); 64 | 65 | verify( 66 | () => mockClient.send( 67 | any( 68 | that: isA() 69 | .having((r) => r.method, 'method', 'GET') 70 | .having((r) => r.url, 'url', url) 71 | .having( 72 | (r) => r.persistentConnection, 73 | 'persistentConnection', 74 | true, 75 | ) 76 | .having( 77 | (r) => r.followRedirects, 78 | 'followRedirects', 79 | true, 80 | ) 81 | .having((r) => r.headers, 'headers', { 82 | 'Accept': 'text/event-stream', 83 | 'Cache-Control': 'no-cache', 84 | }), 85 | ), 86 | ), 87 | ); 88 | }); 89 | 90 | test('emits error if send request fails', () async { 91 | when(() => mockResponse.statusCode).thenReturn(400); 92 | when(() => mockResponse.isRedirect).thenReturn(false); 93 | when(() => mockResponse.persistentConnection).thenReturn(true); 94 | when(() => mockResponse.headers).thenReturn(const {}); 95 | when(() => mockResponse.stream).thenAnswer( 96 | (i) => ByteStream( 97 | Stream.value('error').transform(utf8.encoder), 98 | ), 99 | ); 100 | 101 | final stream = await sut.stream(url); 102 | 103 | expect(stream, emitsError(isA())); 104 | }); 105 | 106 | test('returns transformed stream of SSEs', () async { 107 | when(() => mockResponse.stream).thenAnswer( 108 | (i) => ByteStream( 109 | Stream.value(''' 110 | event: ev1 111 | data: data1 112 | 113 | event: ev2 114 | data: data2 115 | 116 | data: data3 117 | 118 | ''').transform(utf8.encoder), 119 | ), 120 | ); 121 | 122 | final stream = await sut.stream(url); 123 | stream 124 | ..addEventType('ev2') 125 | ..addEventType('ev1') 126 | ..addEventType('ev2') 127 | ..addEventType('message') 128 | ..removeEventType('ev2'); 129 | expect( 130 | stream, 131 | emitsInOrder(const [ 132 | ServerSentEvent(data: 'data1', event: 'ev1'), 133 | ServerSentEvent(data: 'data3'), 134 | ]), 135 | ); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /lib/src/common/filter.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'filter.freezed.dart'; 6 | 7 | /// A helper class to dynamically construct realtime database queries. 8 | /// 9 | /// You can use one of the three factory constructors of this class, 10 | /// [Filter.property], [Filter.key] or [Filter.value] to generate a 11 | /// [FilterBuilder]. This will define how elements are ordere by the server 12 | /// before filters are applied. You can then use the returned builder to apply 13 | /// filters and finally build the actual filter that can be passed to API 14 | /// methods. 15 | /// 16 | /// **Important:** This does not affect the ordering of the received data - 17 | /// after filters have been applied, the data can be returned by the server in 18 | /// any order - if you only (or in addition) need to sort returned data, do so 19 | /// on the client side. 20 | @freezed 21 | class Filter with _$Filter { 22 | const factory Filter._(Map filters) = _Filter; 23 | 24 | /// Order elements by a certain child value before filtering them. 25 | /// 26 | /// When using `orderBy` with the name of a child key, data that contains the 27 | /// specified child key will be ordered as follows: 28 | /// 29 | /// 1. Children with a `null` value for the specified child key come first. 30 | /// 2. Children with a value of `false` for the specified child key come next. 31 | /// If multiple children have a value of `false`, they are sorted 32 | /// lexicographically by key. 33 | /// 3. Children with a value of `true` for the specified child key come next. 34 | /// If multiple children have a value of `true`, they are sorted 35 | /// lexicographically by key. 36 | /// 4. Children with a numeric value come next, sorted in ascending order. 37 | /// If multiple children have the same numerical value for the specified 38 | /// child node, they are sorted by key. 39 | /// 5. Strings come after numbers, and are sorted lexicographically in 40 | /// ascending order. If multiple children have the same value for the 41 | /// specified child node, they are ordered lexicographically by key. 42 | /// 6. Objects come last, and sorted lexicographically by key in ascending 43 | /// order. 44 | /// 45 | /// The filtered results are returned unordered. If the order of your data is 46 | /// important you should sort the results in your application after they are 47 | /// returned from Firebase. 48 | static FilterBuilder property(String property) => 49 | FilterBuilder._(property); 50 | 51 | /// Order elements by their key before filtering them. 52 | /// 53 | /// When using the `orderBy="$key"` parameter to sort your data, data will be 54 | /// returned in ascending order by key as follows. Keep in mind that keys can 55 | /// only be strings. 56 | /// 57 | /// 1. Children with a key that can be parsed as a 32-bit integer come first, 58 | /// sorted in ascending order. 59 | /// 2. Children with a string value as their key come next, sorted 60 | /// lexicographically in ascending order. 61 | static FilterBuilder key() => FilterBuilder._(r'$key'); 62 | 63 | /// Order elements by their value before filtering them. 64 | /// 65 | /// When using the `orderBy="$value"` parameter to sort your data, children 66 | /// will be ordered by their value. The ordering criteria is the same as data 67 | /// ordered by a child key, except the value of the node is used instead of 68 | /// the value of a specified child key. 69 | static FilterBuilder value() => FilterBuilder._(r'$value'); 70 | } 71 | 72 | /// A helper class to build filter queries. 73 | /// 74 | /// See [Filter] for more details. 75 | class FilterBuilder { 76 | final String _orderyBy; 77 | final _queries = {}; 78 | 79 | FilterBuilder._(this._orderyBy); 80 | 81 | /// Limits query results to the first [count] elements 82 | FilterBuilder limitToFirst(int count) { 83 | _queries['limitToFirst'] = json.encode(count); 84 | return this; 85 | } 86 | 87 | /// Limits query results to the last [count] elements 88 | FilterBuilder limitToLast(int count) { 89 | _queries['limitToLast'] = json.encode(count); 90 | return this; 91 | } 92 | 93 | /// Only returns results considered greater or equal to [value] 94 | FilterBuilder startAt(T value) { 95 | _queries['startAt'] = json.encode(value); 96 | return this; 97 | } 98 | 99 | /// Only returns results considered less or equal to [value] 100 | FilterBuilder endAt(T value) { 101 | _queries['endAt'] = json.encode(value); 102 | return this; 103 | } 104 | 105 | /// Only returns results considered equal to [value] 106 | FilterBuilder equalTo(T value) { 107 | _queries['equalTo'] = json.encode(value); 108 | return this; 109 | } 110 | 111 | /// Generates the [Filter] that can be passed to API methods. 112 | Filter build() => Filter._({ 113 | 'orderBy': json.encode(_orderyBy), 114 | ..._queries, 115 | }); 116 | } 117 | -------------------------------------------------------------------------------- /test/unit/rest/stream_event_transformer_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:firebase_database_rest/src/common/api_constants.dart'; 4 | import 'package:firebase_database_rest/src/common/db_exception.dart'; 5 | import 'package:firebase_database_rest/src/rest/models/stream_event.dart'; 6 | import 'package:firebase_database_rest/src/rest/models/unknown_stream_event_error.dart'; 7 | import 'package:firebase_database_rest/src/rest/stream_event_transformer.dart'; 8 | import 'package:firebase_database_rest/src/stream/server_sent_event.dart'; 9 | import 'package:mocktail/mocktail.dart'; 10 | import 'package:test/test.dart'; 11 | import 'package:tuple/tuple.dart'; 12 | 13 | import '../../test_data.dart'; 14 | 15 | class MockStreamEventSink extends Mock implements EventSink {} 16 | 17 | void main() { 18 | group('StreamEventTransformerSink', () { 19 | final mockStreamEventSink = MockStreamEventSink(); 20 | 21 | late StreamEventTransformerSink sut; 22 | 23 | setUp(() { 24 | reset(mockStreamEventSink); 25 | 26 | sut = StreamEventTransformerSink(mockStreamEventSink); 27 | }); 28 | 29 | tearDown(() { 30 | sut.close(); 31 | }); 32 | 33 | group('add', () { 34 | testData>( 35 | 'maps events correctly', 36 | const [ 37 | Tuple2( 38 | ServerSentEvent(event: 'put', data: '{"path": "p", "data": null}'), 39 | StreamEvent.put(path: 'p', data: null), 40 | ), 41 | Tuple2( 42 | ServerSentEvent(event: 'put', data: '{"path": "p", "data": true}'), 43 | StreamEvent.put(path: 'p', data: true), 44 | ), 45 | Tuple2( 46 | ServerSentEvent(event: 'put', data: '{"path": "p", "data": [1,2]}'), 47 | StreamEvent.put(path: 'p', data: [1, 2]), 48 | ), 49 | Tuple2( 50 | ServerSentEvent(event: 'patch', data: '{"path": "p", "data": 0}'), 51 | StreamEvent.patch(path: 'p', data: 0), 52 | ), 53 | Tuple2( 54 | ServerSentEvent(event: 'patch', data: '{"path": "p", "data": "X"}'), 55 | StreamEvent.patch(path: 'p', data: 'X'), 56 | ), 57 | Tuple2( 58 | ServerSentEvent(event: 'keep-alive', data: 'null'), 59 | null, 60 | ), 61 | Tuple2( 62 | ServerSentEvent(event: 'auth_revoked', data: 'null'), 63 | StreamEvent.authRevoked(), 64 | ), 65 | ], 66 | (fixture) { 67 | sut.add(fixture.item1); 68 | if (fixture.item2 != null) { 69 | verify(() => mockStreamEventSink.add(fixture.item2!)); 70 | verifyNoMoreInteractions(mockStreamEventSink); 71 | } else { 72 | verifyZeroInteractions(mockStreamEventSink); 73 | } 74 | }, 75 | ); 76 | 77 | test('maps cancel event to DbException', () { 78 | sut.add(const ServerSentEvent(event: 'cancel', data: 'error message')); 79 | 80 | verify(() => mockStreamEventSink.addError(const DbException( 81 | statusCode: ApiConstants.eventStreamCanceled, 82 | error: 'error message', 83 | ))); 84 | verifyNoMoreInteractions(mockStreamEventSink); 85 | }); 86 | 87 | test('maps unknows events to UnknownStreamEventError', () { 88 | const event = ServerSentEvent( 89 | event: 'unsupported', 90 | data: 'unsupported event', 91 | lastEventId: '42', 92 | ); 93 | sut.add(event); 94 | 95 | verify( 96 | () => mockStreamEventSink.addError( 97 | any(that: predicate((e) { 98 | final err = e! as UnknownStreamEventError; 99 | return err.event == event; 100 | })), 101 | ), 102 | ); 103 | verifyNoMoreInteractions(mockStreamEventSink); 104 | }); 105 | }); 106 | 107 | test('addError forwards addError event', () async { 108 | const error = 'error'; 109 | final trace = StackTrace.current; 110 | 111 | sut.addError(error, trace); 112 | 113 | verify(() => mockStreamEventSink.addError(error, trace)); 114 | }); 115 | 116 | test('close forwards close event', () { 117 | sut.close(); 118 | 119 | verify(() => mockStreamEventSink.close()); 120 | }); 121 | }); 122 | 123 | group('StreamEventTransformer', () { 124 | late StreamEventTransformer sut; 125 | 126 | setUp(() { 127 | // ignore: prefer_const_constructors 128 | sut = StreamEventTransformer(); 129 | }); 130 | 131 | test('bind creates eventTransformed stream', () async { 132 | final boundStream = sut.bind(Stream.fromIterable(const [ 133 | ServerSentEvent( 134 | event: 'put', 135 | data: '{"path": "path", "data": "data"}', 136 | ), 137 | ])); 138 | 139 | final res = await boundStream.single; 140 | 141 | expect(res, const StreamEvent.put(path: 'path', data: 'data')); 142 | }); 143 | 144 | test('cast returns transformed instance', () { 145 | final castTransformer = sut.cast(); 146 | expect(castTransformer, isNotNull); 147 | }); 148 | }); 149 | } 150 | -------------------------------------------------------------------------------- /lib/src/database/store_event.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | import 'package:freezed_annotation/freezed_annotation.dart'; 3 | 4 | import '../../firebase_database_rest.dart'; 5 | 6 | part 'store_event.freezed.dart'; 7 | 8 | /// An interface for patchsets that can be applied to an instance of [T] 9 | /// 10 | /// Patchsets are generated by the library if the server sents patch events. 11 | /// They contain the patch information and can be applied to an instance of [T] 12 | /// to copy the instance and return a modified version with the patch applied. 13 | @immutable 14 | abstract class PatchSet { 15 | const PatchSet._(); 16 | 17 | /// Applies the patch to the given [value] and returns the patched copy of it. 18 | T apply(T value); 19 | } 20 | 21 | /// An event produced by streams of [FirebaseStore] to watch changes on the 22 | /// store. 23 | @freezed 24 | class StoreEvent with _$StoreEvent { 25 | /// Indicates a full store state, with all [data] as currently stored on the 26 | /// server. 27 | const factory StoreEvent.reset( 28 | /// The complete store state, with all current keys and values. 29 | Map data, 30 | ) = _StoreReset; 31 | 32 | /// Indicates the entry under [key] has been updated to a new [value]. 33 | const factory StoreEvent.put( 34 | /// The key of the data that has been updated. 35 | String key, 36 | 37 | /// The updated data. 38 | T value, 39 | ) = _StorePut; 40 | 41 | /// Indicates the entry under [key] has been deleted. 42 | const factory StoreEvent.delete( 43 | /// The key of the data that has been deleted. 44 | String key, 45 | ) = _StoreDelete; 46 | 47 | /// Indicates the entry under [key] has been patched with [patchSet]. 48 | const factory StoreEvent.patch( 49 | /// The key of the data that has been patched. 50 | String key, 51 | 52 | /// The patchSet set that can be applied to the current value. 53 | PatchSet patchSet, 54 | ) = _StorePatch; 55 | 56 | /// Indicates that data was modified at an unsupported [path]. 57 | /// 58 | /// This event appears whenever data modified is not a direct child of the 59 | /// store, as the library does not support deep events. It is an event instead 60 | /// of an error as it can happen quite often depening on how you use the 61 | /// database. Typically, this event can simply be ignored or logged. 62 | const factory StoreEvent.invalidPath( 63 | /// The invalid subpath that was modified on the server. 64 | String path, 65 | ) = _StoreInvalidPath; 66 | } 67 | 68 | /// An event produced by streams of [FirebaseStore] to watch changes on the 69 | /// store keys. 70 | @freezed 71 | class KeyEvent with _$KeyEvent { 72 | /// Indicates a full store state, with all [keys] as currently stored on the 73 | /// server. 74 | const factory KeyEvent.reset( 75 | /// A list with all keys that have data. 76 | List keys, 77 | ) = _KeyReset; 78 | 79 | /// Indicates that the value of [key] has been updated or patched on the 80 | /// server. 81 | const factory KeyEvent.update( 82 | /// The key of the entry that has been updated. 83 | String key, 84 | ) = _KeyUpdate; 85 | 86 | /// Indicates that the value of [key] has been deleted on the server. 87 | const factory KeyEvent.delete( 88 | /// The key of the entry that has been deleted. 89 | String key, 90 | ) = _KeyDelete; 91 | 92 | /// Indicates that data was modified at an unsupported [path]. 93 | /// 94 | /// This event appears whenever data modified is not a direct child of the 95 | /// store, as the library does not support deep events. It is an event instead 96 | /// of an error as it can happen quite often depening on how you use the 97 | /// database. Typically, this event can simply be ignored or logged. 98 | const factory KeyEvent.invalidPath( 99 | /// The invalid subpath that was modified on the server. 100 | String path, 101 | ) = _KeyInvalidPath; 102 | } 103 | 104 | /// An event produced by streams of [FirebaseStore] to watch changes on the 105 | /// specific store entry. 106 | @freezed 107 | class ValueEvent with _$ValueEvent { 108 | /// Indicates that the entry has been updated on the server to [data]. 109 | const factory ValueEvent.update( 110 | /// The current value of the entry. 111 | T data, 112 | ) = _ValueUpdate; 113 | 114 | /// Indicates that the value has been patched on the server with [patchSet]. 115 | const factory ValueEvent.patch( 116 | /// The patchSet set that can be applied to the current value. 117 | PatchSet patchSet, 118 | ) = _ValuePatch; 119 | 120 | /// Indicates that the value has been deleted on the server. 121 | const factory ValueEvent.delete() = _ValueDelete; 122 | 123 | /// Indicates that data was modified at an unsupported [path]. 124 | /// 125 | /// This event appears whenever data modified is not a direct child of the 126 | /// store, as the library does not support deep events. It is an event instead 127 | /// of an error as it can happen quite often depening on how you use the 128 | /// database. Typically, this event can simply be ignored or logged. 129 | const factory ValueEvent.invalidPath( 130 | /// The invalid subpath that was modified on the server. 131 | String path, 132 | ) = _ValueInvalidPath; 133 | } 134 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # sources 2 | LIB_FILES = $(shell find ./lib -type f -iname "*.dart") 3 | SRC_FILES = $(shell find ./lib/src -type f -iname "*.dart") 4 | UNIT_TEST_FILES = $(shell find ./test/unit -type f -iname "*.dart") 5 | INTEGRATION_TEST_FILES = $(shell find ./test/integration -type f -iname "*.dart") 6 | TEST_FILES = $(UNIT_TEST_FILES) $(INTEGRATION_TEST_FILES) 7 | 8 | MAKEFILE := $(abspath $(lastword $(MAKEFILE_LIST))) 9 | 10 | #get 11 | .packages: pubspec.yaml 12 | dart pub get 13 | 14 | get: .packages 15 | 16 | get-clean: 17 | rm -rf .dart_tool 18 | rm -rf .packages 19 | $(MAKE) -f $(MAKEFILE) get 20 | 21 | upgrade: .packages 22 | dart pub upgrade 23 | 24 | # hooks 25 | hook: .packages unhook 26 | echo '#!/bin/sh' > .git/hooks/pre-commit 27 | echo 'exec dart run dart_pre_commit -t -p -oany --ansi' >> .git/hooks/pre-commit 28 | chmod a+x .git/hooks/pre-commit 29 | 30 | unhook: 31 | rm -f .git/hooks/pre-commit 32 | 33 | # build 34 | build: .packages 35 | dart run build_runner build 36 | 37 | build-clean: upgrade 38 | dart run build_runner build --delete-conflicting-outputs 39 | 40 | watch: .packages 41 | dart run build_runner watch 42 | 43 | watch-clean: upgrade 44 | dart run build_runner watch --delete-conflicting-outputs 45 | 46 | # analyze 47 | analyze: .packages 48 | dart analyze --fatal-infos 49 | 50 | # unit-tests 51 | unit-tests-vm: get 52 | dart test test/unit 53 | 54 | unit-tests-js: get 55 | dart test -p chrome test/unit 56 | 57 | unit-tests: get 58 | $(MAKE) -f $(MAKEFILE) unit-tests-vm 59 | $(MAKE) -f $(MAKEFILE) unit-tests-js 60 | 61 | # integration-tests 62 | integration-tests-vm: get 63 | @test -n "$(FIREBASE_PROJECT_ID)" 64 | @test -n "$(FIREBASE_API_KEY)" 65 | dart test test/integration 66 | 67 | integration-tests-js: get 68 | @test -n "$(FIREBASE_PROJECT_ID)" 69 | @test -n "$(FIREBASE_API_KEY)" 70 | @echo "part of 'test_config_js.dart';" > test/integration/test_config_js.env.dart 71 | @echo "const _firebaseProjectId = '$(FIREBASE_PROJECT_ID)';" >> test/integration/test_config_js.env.dart 72 | @echo "const _firebaseApiKey = '$(FIREBASE_API_KEY)';" >> test/integration/test_config_js.env.dart 73 | @echo "const _allTestLimit = '$(FIREBASE_ALL_TEST_LIMIT)';" >> test/integration/test_config_js.env.dart 74 | dart test -p chrome test/integration; tmp=$$?; rm test/integration/test_config_js.env.dart; exit $$tmp 75 | 76 | integration-tests: get 77 | $(MAKE) -f $(MAKEFILE) integration-tests-vm 78 | $(MAKE) -f $(MAKEFILE) integration-tests-js 79 | 80 | test: get 81 | $(MAKE) -f $(MAKEFILE) unit-tests 82 | $(MAKE) -f $(MAKEFILE) integration-tests 83 | 84 | # coverage 85 | coverage-vm: .packages 86 | dart test --coverage=coverage test/unit 87 | 88 | coverage-js: .packages 89 | dart test -p chrome --coverage=coverage test/unit 90 | 91 | coverage/.generated: .packages $(SRC_FILES) $(UNIT_TEST_FILES) 92 | @rm -rf coverage 93 | $(MAKE) -f $(MAKEFILE) coverage-vm 94 | $(MAKE) -f $(MAKEFILE) coverage-js 95 | touch coverage/.generated 96 | 97 | coverage/lcov.info: coverage/.generated 98 | dart run coverage:format_coverage --lcov --check-ignore \ 99 | --in coverage \ 100 | --out coverage/lcov.info \ 101 | --packages .dart_tool/package_config.json \ 102 | --report-on lib 103 | 104 | coverage/lcov_cleaned.info: coverage/lcov.info 105 | lcov --remove coverage/lcov.info -output-file coverage/lcov_cleaned.info \ 106 | '**/*.freezed.dart' \ 107 | '**/*.g.dart' 108 | 109 | coverage/html/index.html: coverage/lcov_cleaned.info 110 | genhtml --no-function-coverage -o coverage/html coverage/lcov_cleaned.info 111 | 112 | coverage: coverage/html/index.html 113 | 114 | unit-tests-vm-coverage: 115 | @rm -rf coverage 116 | $(MAKE) -f $(MAKEFILE) coverage-vm 117 | touch coverage/.generated 118 | $(MAKE) -f $(MAKEFILE) coverage/lcov.info 119 | 120 | unit-tests-js-coverage: 121 | @rm -rf coverage 122 | $(MAKE) -f $(MAKEFILE) coverage-js 123 | touch coverage/.generated 124 | $(MAKE) -f $(MAKEFILE) coverage/lcov.info 125 | 126 | unit-tests-coverage: coverage/lcov.info 127 | 128 | coverage-open: coverage/html/index.html 129 | xdg-open coverage/html/index.html || start coverage/html/index.html 130 | 131 | #doc 132 | doc/api/index.html: .packages $(LIB_FILES) 133 | @rm -rf doc 134 | dartdoc --show-progress 135 | 136 | doc: doc/api/index.html 137 | 138 | doc-open: doc 139 | xdg-open doc/api/index.html || start doc/api/index.html 140 | 141 | # publish 142 | pre-publish: 143 | rm lib/src/.gitignore 144 | 145 | post-publish: 146 | echo '# Generated dart files' > lib/src/.gitignore 147 | echo '*.freezed.dart' >> lib/src/.gitignore 148 | echo '*.g.dart' >> lib/src/.gitignore 149 | 150 | publish-dry: .packages 151 | $(MAKE) -f $(MAKEFILE) pre-publish 152 | dart pub publish --dry-run 153 | $(MAKE) -f $(MAKEFILE) post-publish 154 | 155 | publish: .packages 156 | $(MAKE) -f $(MAKEFILE) pre-publish 157 | dart pub publish --force 158 | $(MAKE) -f $(MAKEFILE) post-publish 159 | 160 | # verify 161 | verify: 162 | $(MAKE) -f $(MAKEFILE) build-clean 163 | $(MAKE) -f $(MAKEFILE) analyze 164 | $(MAKE) -f $(MAKEFILE) unit-tests-coverage 165 | $(MAKE) -f $(MAKEFILE) integration-tests 166 | $(MAKE) -f $(MAKEFILE) coverage-open 167 | $(MAKE) -f $(MAKEFILE) doc-open 168 | $(MAKE) -f $(MAKEFILE) publish-dry 169 | 170 | 171 | 172 | .PHONY: build test coverage coverage-vm coverage-js doc -------------------------------------------------------------------------------- /test/unit/database/store_helpers/store_value_event_transformer_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:firebase_database_rest/src/database/auth_revoked_exception.dart'; 4 | import 'package:firebase_database_rest/src/database/store_event.dart'; 5 | import 'package:firebase_database_rest/src/database/store_helpers/store_value_event_transformer.dart'; 6 | import 'package:firebase_database_rest/src/rest/models/stream_event.dart'; 7 | import 'package:freezed_annotation/freezed_annotation.dart'; 8 | import 'package:mocktail/mocktail.dart'; 9 | import 'package:test/test.dart'; 10 | import 'package:tuple/tuple.dart'; 11 | 12 | import '../../../test_data.dart'; 13 | 14 | part 'store_value_event_transformer_test.freezed.dart'; 15 | part 'store_value_event_transformer_test.g.dart'; 16 | 17 | @freezed 18 | class TestModel with _$TestModel { 19 | const factory TestModel(int id, String data) = _TestModel; 20 | 21 | factory TestModel.fromJson(Map json) => 22 | _$TestModelFromJson(json); 23 | } 24 | 25 | @freezed 26 | class TestModelPatchSet 27 | with _$TestModelPatchSet 28 | implements PatchSet { 29 | const TestModelPatchSet._(); 30 | 31 | // ignore: sort_unnamed_constructors_first 32 | const factory TestModelPatchSet(Map data) = 33 | _TestModelPatchSet; 34 | 35 | @override 36 | TestModel apply(TestModel value) => TestModel( 37 | data['id'] as int? ?? value.id, 38 | data['data'] as String? ?? value.data, 39 | ); 40 | } 41 | 42 | class MockValueEventSink extends Mock 43 | implements EventSink> {} 44 | 45 | void main() { 46 | group('StoreValueEventTransformerSink', () { 47 | final mockValueEventSink = MockValueEventSink(); 48 | 49 | late StoreValueEventTransformerSink sut; 50 | 51 | setUp(() { 52 | reset(mockValueEventSink); 53 | 54 | sut = StoreValueEventTransformerSink( 55 | outSink: mockValueEventSink, 56 | dataFromJson: (dynamic json) => 57 | TestModel.fromJson(json as Map), 58 | patchSetFactory: (data) => TestModelPatchSet(data), 59 | ); 60 | }); 61 | 62 | tearDown(() { 63 | sut.close(); 64 | }); 65 | 66 | group('add', () { 67 | testData, TestModel?>>( 68 | 'maps events correctly', 69 | const [ 70 | Tuple3( 71 | StreamEvent.put(path: '/', data: {'id': 1, 'data': 'A'}), 72 | ValueEvent.update(TestModel(1, 'A')), 73 | null, 74 | ), 75 | Tuple3( 76 | StreamEvent.put(path: '/', data: null), 77 | ValueEvent.delete(), 78 | null, 79 | ), 80 | Tuple3( 81 | StreamEvent.put(path: '/id', data: 3), 82 | ValueEvent.invalidPath('/id'), 83 | null, 84 | ), 85 | Tuple3( 86 | StreamEvent.patch(path: '/', data: {'data': 'D'}), 87 | ValueEvent.patch(TestModelPatchSet({'data': 'D'})), 88 | TestModel(4, 'A'), 89 | ), 90 | Tuple3( 91 | StreamEvent.patch(path: '/data', data: 'E'), 92 | ValueEvent.invalidPath('/data'), 93 | null, 94 | ), 95 | ], 96 | (fixture) { 97 | if (fixture.item3 != null) { 98 | sut.add(StreamEvent.put(path: '/', data: fixture.item3!.toJson())); 99 | clearInteractions(mockValueEventSink); 100 | } 101 | 102 | sut.add(fixture.item1); 103 | verify(() => mockValueEventSink.add(fixture.item2)); 104 | verifyNoMoreInteractions(mockValueEventSink); 105 | }, 106 | ); 107 | 108 | test('maps auth revoked to error', () { 109 | sut.add(const StreamEvent.authRevoked()); 110 | verify( 111 | () => mockValueEventSink 112 | .addError(any(that: isA())), 113 | ); 114 | verifyNoMoreInteractions(mockValueEventSink); 115 | }); 116 | }); 117 | 118 | test('addError forwards addError event', () async { 119 | const error = 'error'; 120 | final trace = StackTrace.current; 121 | 122 | sut.addError(error, trace); 123 | 124 | verify(() => mockValueEventSink.addError(error, trace)); 125 | }); 126 | 127 | test('close forwards close event', () { 128 | sut.close(); 129 | 130 | verify(() => mockValueEventSink.close()); 131 | }); 132 | }); 133 | 134 | group('StoreEventTransformer', () { 135 | late StoreValueEventTransformer sut; 136 | 137 | setUp(() { 138 | sut = StoreValueEventTransformer( 139 | dataFromJson: (dynamic json) => 140 | TestModel.fromJson(json as Map), 141 | patchSetFactory: (data) => TestModelPatchSet(data), 142 | ); 143 | }); 144 | 145 | test('bind creates eventTransformed stream', () async { 146 | final boundStream = sut.bind(Stream.fromIterable(const [ 147 | StreamEvent.put( 148 | path: '/', 149 | data: {'id': 42, 'data': 'X'}, 150 | ), 151 | ])); 152 | 153 | final res = await boundStream.single; 154 | 155 | expect(res, const ValueEvent.update(TestModel(42, 'X'))); 156 | }); 157 | 158 | test('cast returns transformed instance', () { 159 | final castTransformer = sut.cast>(); 160 | expect(castTransformer, isNotNull); 161 | }); 162 | }); 163 | } 164 | -------------------------------------------------------------------------------- /test/unit/database/store_helpers/store_transaction_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_database_rest/src/common/api_constants.dart'; 2 | import 'package:firebase_database_rest/src/common/db_exception.dart'; 3 | import 'package:firebase_database_rest/src/database/etag_receiver.dart'; 4 | import 'package:firebase_database_rest/src/database/store.dart'; 5 | import 'package:firebase_database_rest/src/database/store_helpers/store_transaction.dart'; 6 | import 'package:firebase_database_rest/src/database/transaction.dart'; 7 | import 'package:mocktail/mocktail.dart'; 8 | import 'package:test/test.dart'; 9 | 10 | class MockFirebaseStore extends Mock implements FirebaseStore {} 11 | 12 | // ignore: avoid_implementing_value_types 13 | class FakeETagReceiver extends Fake implements ETagReceiver {} 14 | 15 | void main() { 16 | const key = 'key'; 17 | const value = 42; 18 | const eTag = 'e_tag'; 19 | final mockFirebaseStore = MockFirebaseStore(); 20 | final fakeETagReceiver = FakeETagReceiver(); 21 | 22 | late StoreTransaction sut; 23 | 24 | setUp(() { 25 | reset(mockFirebaseStore); 26 | 27 | sut = StoreTransaction( 28 | store: mockFirebaseStore, 29 | key: key, 30 | value: value, 31 | eTag: eTag, 32 | eTagReceiver: fakeETagReceiver, 33 | ); 34 | }); 35 | 36 | test('properties are set correctly', () { 37 | expect(sut.key, key); 38 | expect(sut.value, value); 39 | expect(sut.eTag, eTag); 40 | }); 41 | 42 | group('commitUpdate', () { 43 | setUp(() { 44 | when(() => mockFirebaseStore.write( 45 | any(), 46 | any(), 47 | eTag: any(named: 'eTag'), 48 | eTagReceiver: any(named: 'eTagReceiver'), 49 | )).thenAnswer((i) async => 0); 50 | }); 51 | 52 | test('calls store.write', () async { 53 | await sut.commitUpdate(13); 54 | 55 | verify(() => mockFirebaseStore.write( 56 | key, 57 | 13, 58 | eTag: eTag, 59 | eTagReceiver: fakeETagReceiver, 60 | )); 61 | }); 62 | 63 | test('forwards store.write result', () async { 64 | when(() => mockFirebaseStore.write( 65 | any(), 66 | any(), 67 | eTag: any(named: 'eTag'), 68 | eTagReceiver: any(named: 'eTagReceiver'), 69 | )).thenAnswer((i) async => 31); 70 | 71 | final res = await sut.commitUpdate(13); 72 | expect(res, 31); 73 | }); 74 | 75 | test('transforms etag mismatch exceptions', () async { 76 | when(() => mockFirebaseStore.write( 77 | any(), 78 | any(), 79 | eTag: any(named: 'eTag'), 80 | eTagReceiver: any(named: 'eTagReceiver'), 81 | )).thenThrow( 82 | const DbException(statusCode: ApiConstants.statusCodeETagMismatch), 83 | ); 84 | 85 | expect( 86 | () => sut.commitUpdate(13), 87 | throwsA(isA()), 88 | ); 89 | }); 90 | 91 | test('forwards other exceptions', () async { 92 | when(() => mockFirebaseStore.write( 93 | any(), 94 | any(), 95 | eTag: any(named: 'eTag'), 96 | eTagReceiver: any(named: 'eTagReceiver'), 97 | )).thenThrow(Exception('test')); 98 | 99 | expect( 100 | () => sut.commitUpdate(13), 101 | throwsA(isA().having( 102 | (e) => (e as dynamic).message, 103 | 'message', 104 | 'test', 105 | )), 106 | ); 107 | }); 108 | 109 | test('throws when trying to commit twice', () async { 110 | await sut.commitUpdate(1); 111 | expect(() => sut.commitUpdate(2), throwsA(isA())); 112 | }); 113 | }); 114 | 115 | group('commitDelete', () { 116 | setUp(() { 117 | when(() => mockFirebaseStore.delete( 118 | any(), 119 | eTag: any(named: 'eTag'), 120 | eTagReceiver: any(named: 'eTagReceiver'), 121 | )).thenAnswer((i) => Future.value()); 122 | }); 123 | 124 | test('calls store.delete', () async { 125 | await sut.commitDelete(); 126 | 127 | verify(() => mockFirebaseStore.delete( 128 | key, 129 | eTag: eTag, 130 | eTagReceiver: fakeETagReceiver, 131 | )); 132 | }); 133 | 134 | test('transforms etag mismatch exceptions', () async { 135 | when(() => mockFirebaseStore.delete( 136 | any(), 137 | eTag: any(named: 'eTag'), 138 | eTagReceiver: any(named: 'eTagReceiver'), 139 | )).thenThrow( 140 | const DbException(statusCode: ApiConstants.statusCodeETagMismatch), 141 | ); 142 | 143 | expect( 144 | () => sut.commitDelete(), 145 | throwsA(isA()), 146 | ); 147 | }); 148 | 149 | test('forwards other exceptions', () async { 150 | when(() => mockFirebaseStore.delete( 151 | any(), 152 | eTag: any(named: 'eTag'), 153 | eTagReceiver: any(named: 'eTagReceiver'), 154 | )).thenThrow(Exception('test')); 155 | 156 | expect( 157 | () => sut.commitDelete(), 158 | throwsA(isA().having( 159 | (e) => (e as dynamic).message, 160 | 'message', 161 | 'test', 162 | )), 163 | ); 164 | }); 165 | 166 | test('throws when trying to commit twice', () { 167 | sut.commitDelete(); 168 | expect(() => sut.commitDelete(), throwsA(isA())); 169 | }); 170 | }); 171 | } 172 | -------------------------------------------------------------------------------- /test/unit/database/store_helpers/store_event_transformer_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:firebase_database_rest/src/database/auth_revoked_exception.dart'; 4 | import 'package:firebase_database_rest/src/database/store_event.dart'; 5 | import 'package:firebase_database_rest/src/database/store_helpers/store_event_transformer.dart'; 6 | import 'package:firebase_database_rest/src/rest/models/stream_event.dart'; 7 | import 'package:freezed_annotation/freezed_annotation.dart'; 8 | import 'package:mocktail/mocktail.dart'; 9 | import 'package:test/test.dart'; 10 | import 'package:tuple/tuple.dart'; 11 | 12 | import '../../../test_data.dart'; 13 | 14 | part 'store_event_transformer_test.freezed.dart'; 15 | part 'store_event_transformer_test.g.dart'; 16 | 17 | @freezed 18 | class TestModel with _$TestModel { 19 | const factory TestModel(int id, String data) = _TestModel; 20 | 21 | factory TestModel.fromJson(Map json) => 22 | _$TestModelFromJson(json); 23 | } 24 | 25 | @freezed 26 | class TestModelPatchSet 27 | with _$TestModelPatchSet 28 | implements PatchSet { 29 | const TestModelPatchSet._(); 30 | 31 | // ignore: sort_unnamed_constructors_first 32 | const factory TestModelPatchSet(Map data) = 33 | _TestModelPatchSet; 34 | 35 | @override 36 | TestModel apply(TestModel value) => throw UnimplementedError(); 37 | } 38 | 39 | class MockStoreEventSink extends Mock 40 | implements EventSink> {} 41 | 42 | void main() { 43 | group('StoreEventTransformerSink', () { 44 | final mockStoreEventSink = MockStoreEventSink(); 45 | 46 | late StoreEventTransformerSink sut; 47 | 48 | setUp(() { 49 | reset(mockStoreEventSink); 50 | 51 | sut = StoreEventTransformerSink( 52 | outSink: mockStoreEventSink, 53 | dataFromJson: (dynamic json) => 54 | TestModel.fromJson(json as Map), 55 | patchSetFactory: (data) => TestModelPatchSet(data), 56 | ); 57 | }); 58 | 59 | tearDown(() { 60 | sut.close(); 61 | }); 62 | 63 | group('add', () { 64 | testData>>( 65 | 'maps events correctly', 66 | const [ 67 | Tuple2( 68 | StreamEvent.put( 69 | path: '/', 70 | data: { 71 | 'a': {'id': 1, 'data': 'A'}, 72 | 'b': {'id': 2, 'data': 'B'}, 73 | 'c': {'id': 3, 'data': 'C'}, 74 | 'd': null, 75 | }, 76 | ), 77 | StoreEvent.reset({ 78 | 'a': TestModel(1, 'A'), 79 | 'b': TestModel(2, 'B'), 80 | 'c': TestModel(3, 'C'), 81 | }), 82 | ), 83 | Tuple2( 84 | StreamEvent.put( 85 | path: '/', 86 | data: null, 87 | ), 88 | StoreEvent.reset({}), 89 | ), 90 | Tuple2( 91 | StreamEvent.put( 92 | path: '/d', 93 | data: {'id': 4, 'data': 'D'}, 94 | ), 95 | StoreEvent.put('d', TestModel(4, 'D')), 96 | ), 97 | Tuple2( 98 | StreamEvent.put( 99 | path: '/e', 100 | data: null, 101 | ), 102 | StoreEvent.delete('e'), 103 | ), 104 | Tuple2( 105 | StreamEvent.put( 106 | path: '/f/id', 107 | data: 6, 108 | ), 109 | StoreEvent.invalidPath('/f/id'), 110 | ), 111 | Tuple2( 112 | StreamEvent.patch( 113 | path: '/g', 114 | data: {'data': 'G'}, 115 | ), 116 | StoreEvent.patch( 117 | 'g', 118 | TestModelPatchSet({'data': 'G'}), 119 | ), 120 | ), 121 | Tuple2( 122 | StreamEvent.patch( 123 | path: '/h/data', 124 | data: 'H', 125 | ), 126 | StoreEvent.invalidPath('/h/data'), 127 | ), 128 | ], 129 | (fixture) { 130 | sut.add(fixture.item1); 131 | verify(() => mockStoreEventSink.add(fixture.item2)); 132 | verifyNoMoreInteractions(mockStoreEventSink); 133 | }, 134 | ); 135 | 136 | test('maps auth revoked to error', () { 137 | sut.add(const StreamEvent.authRevoked()); 138 | verify( 139 | () => mockStoreEventSink 140 | .addError(any(that: isA())), 141 | ); 142 | verifyNoMoreInteractions(mockStoreEventSink); 143 | }); 144 | }); 145 | 146 | test('addError forwards addError event', () async { 147 | const error = 'error'; 148 | final trace = StackTrace.current; 149 | 150 | sut.addError(error, trace); 151 | 152 | verify(() => mockStoreEventSink.addError(error, trace)); 153 | }); 154 | 155 | test('close forwards close event', () { 156 | sut.close(); 157 | 158 | verify(() => mockStoreEventSink.close()); 159 | }); 160 | }); 161 | 162 | group('StoreEventTransformer', () { 163 | late StoreEventTransformer sut; 164 | 165 | setUp(() { 166 | sut = StoreEventTransformer( 167 | dataFromJson: (dynamic json) => 168 | TestModel.fromJson(json as Map), 169 | patchSetFactory: (data) => TestModelPatchSet(data), 170 | ); 171 | }); 172 | 173 | test('bind creates eventTransformed stream', () async { 174 | final boundStream = sut.bind(Stream.fromIterable(const [ 175 | StreamEvent.put( 176 | path: '/x', 177 | data: {'id': 42, 'data': 'X'}, 178 | ), 179 | ])); 180 | 181 | final res = await boundStream.single; 182 | 183 | expect(res, const StoreEvent.put('x', TestModel(42, 'X'))); 184 | }); 185 | 186 | test('cast returns transformed instance', () { 187 | final castTransformer = sut.cast>(); 188 | expect(castTransformer, isNotNull); 189 | }); 190 | }); 191 | } 192 | -------------------------------------------------------------------------------- /lib/src/database/database.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:firebase_auth_rest/firebase_auth_rest.dart'; 4 | import 'package:http/http.dart'; 5 | 6 | import '../common/api_constants.dart'; 7 | import '../common/timeout.dart'; 8 | import '../rest/rest_api.dart'; 9 | import '../stream/sse_client.dart'; 10 | import 'store.dart'; 11 | 12 | /// The root database that manages access to the firebase realtime database. 13 | /// 14 | /// This class manages the [account] and [api], which are used by all substores 15 | /// created from this database. While the database itself does not provide 16 | /// much functionality, it serves as an entry point to access the server 17 | /// database and to create [FirebaseStore]s from. 18 | class FirebaseDatabase { 19 | StreamSubscription? _idTokenSub; 20 | 21 | /// An optional account used by the database. 22 | /// 23 | /// If used, the database will automatically listen to 24 | /// [FirebaseAccount.idTokenStream] to automatically update the 25 | /// [RestApi.idToken], making it easier to use the database. 26 | final FirebaseAccount? account; 27 | 28 | /// The api beeing used to communicate with the firebase servers. 29 | final RestApi api; 30 | 31 | /// An untyped root store, representing the virtual root used by the database. 32 | final FirebaseStore rootStore; 33 | 34 | /// Constructs a database for an authenticated user. 35 | /// 36 | /// This constructor needs an [account] and a [database]. The [account] is the 37 | /// user account used to authenticate to the API, the [database] is the name 38 | /// of the database to connect to. If you want to connec to a database without 39 | /// a user, use [FirebaseDatabase.unauthenticated] instead. 40 | /// 41 | /// The database will reuse the [Client] of the [FirebaseAccount.api], unless 42 | /// [client] is explicitly specified. Either one is used to create the [api]. 43 | /// If the explicit client is a [SSEClient], that one is used for the API. 44 | /// Otherwise a wrapper is created around [client] to provide the SSE 45 | /// features. 46 | /// 47 | /// By default, the database will connect to the root path of the server 48 | /// database. If you want to connect to a subset of the database, use 49 | /// [basePath] to only connect to that part. This path will also be the path 50 | /// of the [rootStore]. 51 | /// 52 | /// In addition, you can use [timeout] and [writeSizeLimit] to configure the 53 | /// corresponding values of the newly created [RestApi]. 54 | FirebaseDatabase({ 55 | required FirebaseAccount account, 56 | required String database, 57 | String basePath = '', 58 | Timeout? timeout, 59 | WriteSizeLimit? writeSizeLimit, 60 | Client? client, 61 | }) : this.api( 62 | RestApi( 63 | client: client ?? account.api.client, 64 | database: database, 65 | basePath: basePath, 66 | idToken: account.idToken, 67 | timeout: timeout, 68 | writeSizeLimit: writeSizeLimit, 69 | ), 70 | account: account, 71 | ); 72 | 73 | /// Constructs a database without a user 74 | /// 75 | /// This constructor needs a [database], the name of the database to connect 76 | /// to. If you want to connect to a database with an authenticated user, use 77 | /// [FirebaseDatabase()] instead. 78 | /// 79 | /// The [client] is required here and used to create the [api]. If the 80 | /// explicit client is a [SSEClient], that one is used for the API. Otherwise 81 | /// a wrapper is created around [client] to provide the SSE features. 82 | /// 83 | /// By default, the database will connect to the root path of the server 84 | /// database. If you want to connect to a subset of the database, use 85 | /// [basePath] to only connect to that part. This path will also be the path 86 | /// of the [rootStore]. 87 | /// 88 | /// In addition, you can use [timeout] and [writeSizeLimit] to configure the 89 | /// corresponding values of the newly created [RestApi]. 90 | FirebaseDatabase.unauthenticated({ 91 | required Client client, 92 | required String database, 93 | String basePath = '', 94 | Timeout? timeout, 95 | WriteSizeLimit? writeSizeLimit, 96 | }) : this.api( 97 | RestApi( 98 | client: client, 99 | database: database, 100 | basePath: basePath, 101 | timeout: timeout, 102 | writeSizeLimit: writeSizeLimit, 103 | ), 104 | ); 105 | 106 | /// Constructs a rawdatabase from the raw api 107 | /// 108 | /// This constructor creates a database that directly uses a previously 109 | /// created [api]. The database to connect to and other parameters must be 110 | /// directly configured when created the [RestApi]. 111 | /// 112 | /// By default, you have to manually set authentication on the API. However, 113 | /// if you want the [RestApi.idToken] to be automatically updated when an 114 | /// account is updated, you can optionally pass a [account] to this 115 | /// constructor. In that case, the database connects to the 116 | /// [FirebaseAccount.idTokenStream] and streams idToken updates to the [api]. 117 | /// 118 | /// **Note:** Typically, you would use one of the other constructors to create 119 | /// the database. Only use this constructor if you can't use the others. 120 | FirebaseDatabase.api( 121 | this.api, { 122 | this.account, 123 | }) : rootStore = FirebaseStore.apiCreate( 124 | restApi: api, 125 | subPaths: [], 126 | onDataFromJson: (dynamic json) => json, 127 | onDataToJson: (dynamic data) => data, 128 | onPatchData: (dynamic data, updatedFields) => 129 | (data as Map)..addAll(updatedFields), 130 | ) { 131 | if (account != null) { 132 | _idTokenSub = account!.idTokenStream.listen( 133 | (idToken) => api.idToken = idToken, 134 | cancelOnError: false, 135 | ); 136 | } 137 | } 138 | 139 | /// Disposes of the database. 140 | /// 141 | /// This internally canceles any subscriptions to the accounts idTokenStream, 142 | /// if active. 143 | Future dispose() async { 144 | await _idTokenSub?.cancel(); 145 | } 146 | 147 | /// Creates a typed variant of the [rootStore]. 148 | /// 149 | /// Returns a store which is scoped to the database, just like the 150 | /// [rootStore], but with converter callbacks to make it typed to [T]. 151 | /// 152 | /// Iternally uses [FirebaseStore.apiCreate] with [onDataFromJson], 153 | /// [onDataToJson] and [onPatchData] to create the store. 154 | FirebaseStore createRootStore({ 155 | required DataFromJsonCallback onDataFromJson, 156 | required DataToJsonCallback onDataToJson, 157 | required PatchDataCallback onPatchData, 158 | }) => 159 | FirebaseStore.apiCreate( 160 | restApi: api, 161 | subPaths: [], 162 | onDataFromJson: onDataFromJson, 163 | onDataToJson: onDataToJson, 164 | onPatchData: onPatchData, 165 | ); 166 | } 167 | -------------------------------------------------------------------------------- /test/unit/database/auto_renew_stream_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:firebase_database_rest/src/database/auth_revoked_exception.dart'; 4 | import 'package:firebase_database_rest/src/database/auto_renew_stream.dart'; 5 | import 'package:mocktail/mocktail.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | import '../../stream_matcher_queue.dart'; 9 | 10 | abstract class Callable { 11 | Future> call(); 12 | } 13 | 14 | class MockCallable extends Mock implements Callable {} 15 | 16 | void main() { 17 | final mockStreamFactory = MockCallable(); 18 | 19 | late int _authStreamCtr; 20 | Stream _authStream(int limit) async* { 21 | if (_authStreamCtr < limit) { 22 | yield _authStreamCtr++; 23 | throw AuthRevokedException(); 24 | } 25 | } 26 | 27 | Stream _singleAuthStream(Iterable data) async* { 28 | yield* Stream.fromIterable(data); 29 | throw AuthRevokedException(); 30 | } 31 | 32 | setUp(() { 33 | reset(mockStreamFactory); 34 | _authStreamCtr = 0; 35 | }); 36 | 37 | test('creates stream and forwards events', () async { 38 | when(() => mockStreamFactory.call()) 39 | .thenAnswer((i) async => Stream.fromIterable([1, 2, 3, 4])); 40 | 41 | final stream = AutoRenewStream(mockStreamFactory); 42 | expect(await stream.toList(), [1, 2, 3, 4]); 43 | verify(() => mockStreamFactory.call()).called(1); 44 | }); 45 | 46 | test('creates stream that can be canceled', () async { 47 | when(() => mockStreamFactory.call()).thenAnswer( 48 | (i) async => Stream.fromFuture( 49 | Future.delayed(const Duration(milliseconds: 200), () => 42), 50 | ), 51 | ); 52 | 53 | final stream = AutoRenewStream(mockStreamFactory); 54 | final sub = stream.listen((e) => fail('stream was not canceled')); 55 | await Future.delayed(const Duration(milliseconds: 100)); 56 | await sub.cancel(); 57 | await Future.delayed(const Duration(milliseconds: 200)); 58 | verify(() => mockStreamFactory.call()).called(1); 59 | }); 60 | 61 | test('creates stream and forwards exceptions', () async { 62 | when(() => mockStreamFactory.call()) 63 | .thenAnswer((i) async => Stream.fromFuture(Future.error(Exception()))); 64 | 65 | final stream = AutoRenewStream(mockStreamFactory); 66 | expect(() => stream.toList(), throwsA(isA())); 67 | verify(() => mockStreamFactory.call()).called(1); 68 | }); 69 | 70 | test('creates stream and forwards exceptions, without canceling', () async { 71 | when(() => mockStreamFactory.call()).thenAnswer( 72 | (i) async => Stream.fromFutures([ 73 | Future.delayed(const Duration(milliseconds: 100), () => 1), 74 | Future.delayed( 75 | const Duration(milliseconds: 200), 76 | () => Future.error(Exception()), 77 | ), 78 | Future.delayed(const Duration(milliseconds: 300), () => 2), 79 | ]), 80 | ); 81 | 82 | final stream = StreamMatcherQueue(AutoRenewStream(mockStreamFactory)); 83 | try { 84 | await expectLater( 85 | stream, 86 | emitsQueued([ 87 | 1, 88 | asError(isA()), 89 | 2, 90 | isNull, 91 | ]), 92 | ); 93 | } finally { 94 | await stream.close(); 95 | } 96 | expect(stream, isEmpty); 97 | }); 98 | 99 | test('creates stream that can be paused and resumed', () async { 100 | when(() => mockStreamFactory.call()).thenAnswer( 101 | (i) async => () async* { 102 | yield 1; 103 | yield 2; 104 | await Future.delayed(const Duration(milliseconds: 200)); 105 | yield 3; 106 | yield 4; 107 | }(), 108 | ); 109 | 110 | final stream = StreamMatcherQueue(AutoRenewStream(mockStreamFactory)); 111 | try { 112 | await Future.delayed(const Duration(milliseconds: 100)); 113 | stream.sub.pause(); 114 | await expectLater(stream, emitsQueued(const [1, 2])); 115 | expect(stream, isEmpty); 116 | 117 | await Future.delayed(const Duration(milliseconds: 200)); 118 | expect(stream, isEmpty); 119 | 120 | stream.sub.resume(); 121 | await expectLater(stream, emitsQueued(const [3, 4, null])); 122 | } finally { 123 | await stream.close(); 124 | } 125 | expect(stream, isEmpty); 126 | }); 127 | 128 | test('recreates stream on auth exception', () async { 129 | when(() => mockStreamFactory.call()) 130 | .thenAnswer((i) async => _authStream(4)); 131 | 132 | final stream = AutoRenewStream(mockStreamFactory); 133 | expect(await stream.toList(), [0, 1, 2, 3]); 134 | verify(() => mockStreamFactory.call()).called(5); 135 | }); 136 | 137 | test('fromStream: uses base stream and then renew', () async { 138 | when(() => mockStreamFactory.call()) 139 | .thenAnswer((i) async => _authStream(2)); 140 | 141 | final stream = AutoRenewStream.fromStream( 142 | _singleAuthStream(const [7, 8, 9]), 143 | mockStreamFactory, 144 | ); 145 | expect(await stream.toList(), [7, 8, 9, 0, 1]); 146 | verify(() => mockStreamFactory.call()).called(3); 147 | }); 148 | 149 | test( 150 | 'can cancel while refreshing', 151 | () async { 152 | when(() => mockStreamFactory.call()).thenAnswer( 153 | (i) async => Stream.fromFuture( 154 | Future.delayed(const Duration(milliseconds: 250), () => 42), 155 | ), 156 | ); 157 | 158 | final stream = StreamMatcherQueue(AutoRenewStream.fromStream( 159 | _singleAuthStream(const [1, 2, 3]), 160 | mockStreamFactory, 161 | )); 162 | try { 163 | await expectLater(stream, emitsQueued(const [1, 2, 3])); 164 | await Future.delayed(const Duration(milliseconds: 100)); 165 | await stream.sub.cancel(); 166 | expect(stream, isEmpty); 167 | 168 | await Future.delayed(const Duration(milliseconds: 300)); 169 | expect(stream, isEmpty); 170 | } finally { 171 | await stream.close(); 172 | } 173 | expect(stream, isEmpty); 174 | }, 175 | timeout: const Timeout(Duration(seconds: 3)), 176 | ); 177 | 178 | test('can pause while refreshing', () async { 179 | when(() => mockStreamFactory.call()).thenAnswer( 180 | (i) async => Stream.fromFuture( 181 | Future.delayed(const Duration(milliseconds: 250), () => 42), 182 | ), 183 | ); 184 | 185 | final stream = StreamMatcherQueue(AutoRenewStream.fromStream( 186 | _singleAuthStream(const [1, 2, 3]), 187 | mockStreamFactory, 188 | )); 189 | try { 190 | await expectLater(stream, emitsQueued(const [1, 2, 3])); 191 | await Future.delayed(const Duration(milliseconds: 100)); 192 | stream.sub.pause(); 193 | expect(stream, isEmpty); 194 | 195 | await Future.delayed(const Duration(milliseconds: 300)); 196 | expect(stream, isEmpty); 197 | 198 | stream.sub.resume(); 199 | await expectLater(stream, emitsQueued(const [42, null])); 200 | } finally { 201 | await stream.close(); 202 | } 203 | expect(stream, isEmpty); 204 | }); 205 | } 206 | -------------------------------------------------------------------------------- /test/unit/stream/vm/event_stream_decoder_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:firebase_database_rest/src/stream/server_sent_event.dart'; 4 | import 'package:firebase_database_rest/src/stream/vm/event_stream_decoder.dart'; 5 | import 'package:mocktail/mocktail.dart'; 6 | import 'package:test/test.dart'; 7 | import 'package:tuple/tuple.dart'; 8 | 9 | import '../../../test_data.dart'; 10 | 11 | class MockSSESink extends Mock implements EventSink {} 12 | 13 | void main() { 14 | setUpAll(() { 15 | registerFallbackValue(const ServerSentEvent(data: '')); 16 | }); 17 | 18 | group('EventStreamDecoderSink', () { 19 | final mockSSESink = MockSSESink(); 20 | 21 | late EventStreamDecoderSink sut; 22 | 23 | setUp(() { 24 | reset(mockSSESink); 25 | 26 | sut = EventStreamDecoderSink(mockSSESink); 27 | }); 28 | 29 | group('add', () { 30 | testData, ServerSentEvent?>>( 31 | 'generate correct events', 32 | const [ 33 | // empty 34 | Tuple2([], null), 35 | Tuple2([':comment'], null), 36 | // data 37 | Tuple2( 38 | ['data: data'], 39 | ServerSentEvent(event: 'message', data: 'data'), 40 | ), 41 | Tuple2( 42 | ['data:data'], 43 | ServerSentEvent(event: 'message', data: 'data'), 44 | ), 45 | Tuple2( 46 | ['data: data '], 47 | ServerSentEvent(event: 'message', data: ' data '), 48 | ), 49 | Tuple2( 50 | ['data'], 51 | ServerSentEvent(event: 'message', data: ''), 52 | ), 53 | Tuple2( 54 | ['data: data1', 'data: data2', 'data', 'data: data3'], 55 | ServerSentEvent(event: 'message', data: 'data1\ndata2\n\ndata3'), 56 | ), 57 | Tuple2( 58 | [':comment', 'data: data'], 59 | ServerSentEvent(event: 'message', data: 'data'), 60 | ), 61 | // event 62 | Tuple2(['event: event'], null), 63 | Tuple2( 64 | ['event: event', 'data: data'], 65 | ServerSentEvent(event: 'event', data: 'data'), 66 | ), 67 | Tuple2( 68 | ['event:event', 'data: data'], 69 | ServerSentEvent(event: 'event', data: 'data'), 70 | ), 71 | Tuple2( 72 | ['event: event ', 'data: data'], 73 | ServerSentEvent(event: ' event ', data: 'data'), 74 | ), 75 | Tuple2( 76 | ['event', 'data: data'], 77 | ServerSentEvent(event: 'message', data: 'data'), 78 | ), 79 | Tuple2( 80 | ['data: data', 'event: event'], 81 | ServerSentEvent(event: 'event', data: 'data'), 82 | ), 83 | Tuple2( 84 | ['event: event1', 'event: event2', 'data: data'], 85 | ServerSentEvent(event: 'event2', data: 'data'), 86 | ), 87 | Tuple2( 88 | [':comment', 'event: event', 'data: data'], 89 | ServerSentEvent(event: 'event', data: 'data'), 90 | ), 91 | // id 92 | Tuple2(['id: id'], null), 93 | Tuple2( 94 | ['data: data', 'id: id'], 95 | ServerSentEvent(event: 'message', data: 'data', lastEventId: 'id'), 96 | ), 97 | Tuple2( 98 | ['data: data', 'id:id'], 99 | ServerSentEvent(event: 'message', data: 'data', lastEventId: 'id'), 100 | ), 101 | Tuple2( 102 | ['data: data', 'id: id '], 103 | ServerSentEvent( 104 | event: 'message', data: 'data', lastEventId: ' id '), 105 | ), 106 | Tuple2( 107 | ['data: data', 'id'], 108 | ServerSentEvent(event: 'message', data: 'data'), 109 | ), 110 | Tuple2( 111 | ['data: data', 'id: id1', 'id: id2'], 112 | ServerSentEvent(event: 'message', data: 'data', lastEventId: 'id2'), 113 | ), 114 | Tuple2( 115 | [':comment', 'data: data', 'id: id'], 116 | ServerSentEvent(event: 'message', data: 'data', lastEventId: 'id'), 117 | ), 118 | ], 119 | (fixture) { 120 | fixture.item1.forEach(sut.add); 121 | sut.add(''); // complete event 122 | 123 | if (fixture.item2 != null) { 124 | verify(() => mockSSESink.add(fixture.item2!)); 125 | verifyNoMoreInteractions(mockSSESink); 126 | } else { 127 | verifyZeroInteractions(mockSSESink); 128 | } 129 | }, 130 | ); 131 | 132 | test('clears data and event type between events', () { 133 | sut 134 | ..add('data: d1') 135 | ..add('data: d2') 136 | ..add('event: e1') 137 | ..add('') 138 | ..add('data: d3') 139 | ..add(''); 140 | 141 | verify( 142 | () => mockSSESink 143 | .add(const ServerSentEvent(data: 'd1\nd2', event: 'e1')), 144 | ); 145 | verify( 146 | () => mockSSESink 147 | .add(const ServerSentEvent(data: 'd3', event: 'message')), 148 | ); 149 | }); 150 | 151 | test('keeps last event id until cleared', () { 152 | sut 153 | ..add('data: d1') 154 | ..add('id: id1') 155 | ..add('') 156 | ..add('data: d2') 157 | ..add('') 158 | ..add('data: d3') 159 | ..add('id') 160 | ..add(''); 161 | 162 | verify( 163 | () => mockSSESink 164 | .add(const ServerSentEvent(data: 'd1', lastEventId: 'id1')), 165 | ); 166 | verify( 167 | () => mockSSESink 168 | .add(const ServerSentEvent(data: 'd2', lastEventId: 'id1')), 169 | ); 170 | verify( 171 | () => mockSSESink.add(const ServerSentEvent(data: 'd3')), 172 | ); 173 | }); 174 | }); 175 | 176 | test('addError forwards addError event', () async { 177 | const error = 'error'; 178 | final trace = StackTrace.current; 179 | 180 | sut.addError(error, trace); 181 | 182 | verify(() => mockSSESink.addError(error, trace)); 183 | }); 184 | 185 | group('close', () { 186 | test('forwards close event', () { 187 | sut.close(); 188 | 189 | verify(() => mockSSESink.close()); 190 | }); 191 | 192 | test('does not complete partial events', () { 193 | sut 194 | ..add('data: d1') 195 | ..close(); 196 | 197 | verifyNever(() => mockSSESink.add(any())); 198 | }); 199 | }); 200 | }); 201 | 202 | group('EventStreamDecoder', () { 203 | late EventStreamDecoder sut; 204 | 205 | setUp(() { 206 | // ignore: prefer_const_constructors 207 | sut = EventStreamDecoder(); 208 | }); 209 | 210 | test('bind creates eventTransformed stream', () async { 211 | final boundStream = sut.bind(Stream.fromIterable( 212 | const ['data: data', ''], 213 | )); 214 | 215 | final res = await boundStream.single; 216 | 217 | expect(res, const ServerSentEvent(data: 'data')); 218 | }); 219 | 220 | test('cast returns transformed instance', () { 221 | final castTransformer = sut.cast(); 222 | expect(castTransformer, isNotNull); 223 | }); 224 | }); 225 | } 226 | -------------------------------------------------------------------------------- /test/unit/stream/sse_client_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:firebase_database_rest/src/stream/sse_client_factory.dart'; 5 | import 'package:http/http.dart'; 6 | import 'package:mocktail/mocktail.dart'; 7 | import 'package:test/test.dart'; 8 | import 'sse_client_test_fallback.dart' 9 | if (dart.library.io) 'vm/sse_client_test_vm.dart' 10 | if (dart.library.html) 'js/sse_client_test_js.dart'; 11 | 12 | class MockClient extends Mock implements Client {} 13 | 14 | class FakeRequest extends Fake implements Request {} 15 | 16 | class FakeResponse extends Fake implements Response {} 17 | 18 | class FakeStreamedResponse extends Fake implements StreamedResponse {} 19 | 20 | class SutClientProxy with ClientProxy implements Client { 21 | @override 22 | final Client client; 23 | 24 | SutClientProxy(this.client); 25 | } 26 | 27 | void main() { 28 | setUpAll(() { 29 | registerFallbackValue(Uri()); 30 | registerFallbackValue(FakeRequest()); 31 | }); 32 | 33 | test('fallback factory throws exception', () { 34 | expect( 35 | () => SSEClientFactory.create(MockClient()), 36 | throwsA(isA()), 37 | ); 38 | }); 39 | 40 | setupTests(); 41 | 42 | group('ClientProxy', () { 43 | final mockClient = MockClient(); 44 | 45 | late ClientProxy sut; 46 | 47 | setUp(() { 48 | reset(mockClient); 49 | 50 | sut = SutClientProxy(mockClient); 51 | }); 52 | 53 | test('close proxies to close', () { 54 | sut.close(); 55 | 56 | verify(() => mockClient.close()); 57 | }); 58 | 59 | test('delete proxies to delete', () async { 60 | final response = FakeResponse(); 61 | final url = Uri.http('localhost', '/'); 62 | const headers = { 63 | 'A': 'B', 64 | }; 65 | const body = 'body'; 66 | 67 | when(() => mockClient.delete( 68 | any(), 69 | headers: any(named: 'headers'), 70 | body: any(named: 'body'), 71 | encoding: any(named: 'encoding'), 72 | )).thenAnswer((i) async => response); 73 | 74 | final res = await sut.delete( 75 | url, 76 | headers: headers, 77 | body: body, 78 | encoding: utf8, 79 | ); 80 | 81 | expect(res, response); 82 | verify( 83 | () => mockClient.delete( 84 | url, 85 | headers: headers, 86 | body: body, 87 | encoding: utf8, 88 | ), 89 | ); 90 | }); 91 | 92 | test('get proxies to get', () async { 93 | final response = FakeResponse(); 94 | final url = Uri.http('localhost', '/'); 95 | const headers = { 96 | 'A': 'B', 97 | }; 98 | 99 | when(() => mockClient.get( 100 | any(), 101 | headers: any(named: 'headers'), 102 | )).thenAnswer((i) async => response); 103 | 104 | final res = await sut.get( 105 | url, 106 | headers: headers, 107 | ); 108 | 109 | expect(res, response); 110 | verify( 111 | () => mockClient.get( 112 | url, 113 | headers: headers, 114 | ), 115 | ); 116 | }); 117 | 118 | test('head proxies to head', () async { 119 | final response = FakeResponse(); 120 | final url = Uri.http('localhost', '/'); 121 | const headers = { 122 | 'A': 'B', 123 | }; 124 | 125 | when(() => mockClient.head( 126 | any(), 127 | headers: any(named: 'headers'), 128 | )).thenAnswer((i) async => response); 129 | 130 | final res = await sut.head( 131 | url, 132 | headers: headers, 133 | ); 134 | 135 | expect(res, response); 136 | verify( 137 | () => mockClient.head( 138 | url, 139 | headers: headers, 140 | ), 141 | ); 142 | }); 143 | 144 | test('patch proxies to patch', () async { 145 | final response = FakeResponse(); 146 | final url = Uri.http('localhost', '/'); 147 | const headers = { 148 | 'A': 'B', 149 | }; 150 | const body = 'body'; 151 | 152 | when(() => mockClient.patch( 153 | any(), 154 | headers: any(named: 'headers'), 155 | body: any(named: 'body'), 156 | encoding: any(named: 'encoding'), 157 | )).thenAnswer((i) async => response); 158 | 159 | final res = await sut.patch( 160 | url, 161 | headers: headers, 162 | body: body, 163 | encoding: utf8, 164 | ); 165 | 166 | expect(res, response); 167 | verify( 168 | () => mockClient.patch( 169 | url, 170 | headers: headers, 171 | body: body, 172 | encoding: utf8, 173 | ), 174 | ); 175 | }); 176 | 177 | test('post proxies to post', () async { 178 | final response = FakeResponse(); 179 | final url = Uri.http('localhost', '/'); 180 | const headers = { 181 | 'A': 'B', 182 | }; 183 | const body = 'body'; 184 | 185 | when(() => mockClient.post( 186 | any(), 187 | headers: any(named: 'headers'), 188 | body: any(named: 'body'), 189 | encoding: any(named: 'encoding'), 190 | )).thenAnswer((i) async => response); 191 | 192 | final res = await sut.post( 193 | url, 194 | headers: headers, 195 | body: body, 196 | encoding: utf8, 197 | ); 198 | 199 | expect(res, response); 200 | verify( 201 | () => mockClient.post( 202 | url, 203 | headers: headers, 204 | body: body, 205 | encoding: utf8, 206 | ), 207 | ); 208 | }); 209 | 210 | test('put proxies to put', () async { 211 | final response = FakeResponse(); 212 | final url = Uri.http('localhost', '/'); 213 | const headers = { 214 | 'A': 'B', 215 | }; 216 | const body = 'body'; 217 | 218 | when(() => mockClient.put( 219 | any(), 220 | headers: any(named: 'headers'), 221 | body: any(named: 'body'), 222 | encoding: any(named: 'encoding'), 223 | )).thenAnswer((i) async => response); 224 | 225 | final res = await sut.put( 226 | url, 227 | headers: headers, 228 | body: body, 229 | encoding: utf8, 230 | ); 231 | 232 | expect(res, response); 233 | verify( 234 | () => mockClient.put( 235 | url, 236 | headers: headers, 237 | body: body, 238 | encoding: utf8, 239 | ), 240 | ); 241 | }); 242 | 243 | test('read proxies to read', () async { 244 | const response = 'response'; 245 | final url = Uri.http('localhost', '/'); 246 | const headers = { 247 | 'A': 'B', 248 | }; 249 | 250 | when(() => mockClient.read( 251 | any(), 252 | headers: any(named: 'headers'), 253 | )).thenAnswer((i) async => response); 254 | 255 | final res = await sut.read( 256 | url, 257 | headers: headers, 258 | ); 259 | 260 | expect(res, response); 261 | verify( 262 | () => mockClient.read( 263 | url, 264 | headers: headers, 265 | ), 266 | ); 267 | }); 268 | 269 | test('readBytes proxies to readBytes', () async { 270 | final response = Uint8List(10); 271 | final url = Uri.http('localhost', '/'); 272 | const headers = { 273 | 'A': 'B', 274 | }; 275 | 276 | when(() => mockClient.readBytes( 277 | any(), 278 | headers: any(named: 'headers'), 279 | )).thenAnswer((i) async => response); 280 | 281 | final res = await sut.readBytes( 282 | url, 283 | headers: headers, 284 | ); 285 | 286 | expect(res, response); 287 | verify( 288 | () => mockClient.readBytes( 289 | url, 290 | headers: headers, 291 | ), 292 | ); 293 | }); 294 | 295 | test('send proxies to send', () async { 296 | final response = FakeStreamedResponse(); 297 | final request = FakeRequest(); 298 | 299 | when(() => mockClient.send(any())).thenAnswer((i) async => response); 300 | 301 | final res = await sut.send(request); 302 | 303 | expect(res, response); 304 | verify(() => mockClient.send(request)); 305 | }); 306 | }); 307 | } 308 | -------------------------------------------------------------------------------- /test/unit/database/database_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:firebase_auth_rest/firebase_auth_rest.dart'; 4 | import 'package:firebase_auth_rest/rest.dart' as auth_rest; 5 | import 'package:firebase_database_rest/src/common/api_constants.dart'; 6 | import 'package:firebase_database_rest/src/common/timeout.dart'; 7 | import 'package:firebase_database_rest/src/database/database.dart'; 8 | import 'package:firebase_database_rest/src/database/store_helpers/callback_store.dart'; 9 | import 'package:firebase_database_rest/src/rest/rest_api.dart'; 10 | import 'package:firebase_database_rest/src/stream/sse_client.dart'; 11 | import 'package:mocktail/mocktail.dart'; 12 | import 'package:test/test.dart' hide Timeout; 13 | 14 | class MockFirebaseAccount extends Mock implements FirebaseAccount {} 15 | 16 | class MockAuthRestApi extends Mock implements auth_rest.RestApi {} 17 | 18 | class MockSSEClient extends Mock implements SSEClient {} 19 | 20 | class MockRestApi extends Mock implements RestApi {} 21 | 22 | class MockIdTokenStream extends Mock implements Stream {} 23 | 24 | class MockIdTokenStreamSub extends Mock implements StreamSubscription {} 25 | 26 | void main() { 27 | const testDb = 'test-db'; 28 | final mockFirebaseAccount = MockFirebaseAccount(); 29 | final mockAuthRestApi = MockAuthRestApi(); 30 | final mockSSEClient = MockSSEClient(); 31 | final mockRestApi = MockRestApi(); 32 | 33 | setUp(() { 34 | reset(mockFirebaseAccount); 35 | reset(mockAuthRestApi); 36 | reset(mockSSEClient); 37 | reset(mockRestApi); 38 | 39 | when(() => mockFirebaseAccount.api).thenReturn(mockAuthRestApi); 40 | when(() => mockFirebaseAccount.idTokenStream) 41 | .thenAnswer((i) => const Stream.empty()); 42 | }); 43 | 44 | group('construction', () { 45 | group('default', () { 46 | test('builds database as expected', () { 47 | const idToken = 'id-token'; 48 | 49 | when(() => mockFirebaseAccount.idToken).thenReturn(idToken); 50 | when(() => mockAuthRestApi.client).thenReturn(mockSSEClient); 51 | 52 | final sut = FirebaseDatabase( 53 | account: mockFirebaseAccount, 54 | database: testDb, 55 | ); 56 | 57 | expect(sut.account, mockFirebaseAccount); 58 | expect(sut.api.client, mockSSEClient); 59 | expect(sut.api.database, testDb); 60 | expect(sut.api.basePath, isEmpty); 61 | expect(sut.api.idToken, idToken); 62 | expect(sut.api.timeout, isNull); 63 | expect(sut.api.writeSizeLimit, isNull); 64 | expect(sut.rootStore, isA()); 65 | expect(sut.rootStore.restApi, sut.api); 66 | expect(sut.rootStore.subPaths, isEmpty); 67 | 68 | verify(() => mockFirebaseAccount.idTokenStream); 69 | }); 70 | 71 | test('honors all parameters', () { 72 | const idToken = 'id-token'; 73 | const basePath = '/base/path'; 74 | const timeout = Timeout.min(5); 75 | const writeSizeLimit = WriteSizeLimit.medium; 76 | 77 | when(() => mockFirebaseAccount.idToken).thenReturn(idToken); 78 | 79 | final sut = FirebaseDatabase( 80 | account: mockFirebaseAccount, 81 | database: testDb, 82 | basePath: basePath, 83 | client: mockSSEClient, 84 | timeout: timeout, 85 | writeSizeLimit: writeSizeLimit, 86 | ); 87 | 88 | expect(sut.account, mockFirebaseAccount); 89 | expect(sut.api.client, mockSSEClient); 90 | expect(sut.api.database, testDb); 91 | expect(sut.api.basePath, basePath); 92 | expect(sut.api.idToken, idToken); 93 | expect(sut.api.timeout, timeout); 94 | expect(sut.api.writeSizeLimit, writeSizeLimit); 95 | expect(sut.rootStore, isA()); 96 | expect(sut.rootStore.restApi, sut.api); 97 | expect(sut.rootStore.subPaths, isEmpty); 98 | 99 | verify(() => mockFirebaseAccount.idTokenStream); 100 | }); 101 | }); 102 | 103 | group('unauthenticated', () { 104 | test('builds database as expected', () { 105 | final sut = FirebaseDatabase.unauthenticated( 106 | client: mockSSEClient, 107 | database: testDb, 108 | ); 109 | 110 | expect(sut.account, isNull); 111 | expect(sut.api.client, mockSSEClient); 112 | expect(sut.api.database, testDb); 113 | expect(sut.api.basePath, isEmpty); 114 | expect(sut.api.idToken, isNull); 115 | expect(sut.api.timeout, isNull); 116 | expect(sut.api.writeSizeLimit, isNull); 117 | expect(sut.rootStore, isA()); 118 | expect(sut.rootStore.restApi, sut.api); 119 | expect(sut.rootStore.subPaths, isEmpty); 120 | 121 | verifyNever(() => mockFirebaseAccount.idTokenStream); 122 | }); 123 | 124 | test('honors all parameters', () { 125 | const basePath = '/base/path'; 126 | const timeout = Timeout.min(5); 127 | const writeSizeLimit = WriteSizeLimit.medium; 128 | 129 | final sut = FirebaseDatabase.unauthenticated( 130 | database: testDb, 131 | basePath: basePath, 132 | client: mockSSEClient, 133 | timeout: timeout, 134 | writeSizeLimit: writeSizeLimit, 135 | ); 136 | 137 | expect(sut.account, isNull); 138 | expect(sut.api.client, mockSSEClient); 139 | expect(sut.api.database, testDb); 140 | expect(sut.api.basePath, basePath); 141 | expect(sut.api.idToken, isNull); 142 | expect(sut.api.timeout, timeout); 143 | expect(sut.api.writeSizeLimit, writeSizeLimit); 144 | expect(sut.rootStore, isA()); 145 | expect(sut.rootStore.restApi, sut.api); 146 | expect(sut.rootStore.subPaths, isEmpty); 147 | 148 | verifyNever(() => mockFirebaseAccount.idTokenStream); 149 | }); 150 | }); 151 | 152 | group('api', () { 153 | test('builds database as expected', () { 154 | final sut = FirebaseDatabase.api(mockRestApi); 155 | 156 | expect(sut.account, isNull); 157 | expect(sut.api, mockRestApi); 158 | expect(sut.rootStore, isA()); 159 | expect(sut.rootStore.restApi, mockRestApi); 160 | expect(sut.rootStore.subPaths, isEmpty); 161 | 162 | verifyNever(() => mockFirebaseAccount.idTokenStream); 163 | }); 164 | 165 | test('registers token stream if account is given', () { 166 | final sut = FirebaseDatabase.api( 167 | mockRestApi, 168 | account: mockFirebaseAccount, 169 | ); 170 | 171 | expect(sut.account, mockFirebaseAccount); 172 | expect(sut.api, mockRestApi); 173 | expect(sut.rootStore, isA()); 174 | expect(sut.rootStore.restApi, mockRestApi); 175 | expect(sut.rootStore.subPaths, isEmpty); 176 | 177 | verify(() => mockFirebaseAccount.idTokenStream); 178 | }); 179 | 180 | test('updates api idToken if idTokenStream produces one', () async { 181 | const idToken = 'id-token-update'; 182 | when(() => mockFirebaseAccount.idTokenStream) 183 | .thenAnswer((i) => Stream.value(idToken)); 184 | 185 | FirebaseDatabase.api( 186 | mockRestApi, 187 | account: mockFirebaseAccount, 188 | ); 189 | 190 | verify(() => mockFirebaseAccount.idTokenStream); 191 | 192 | await Future.delayed(const Duration(milliseconds: 500)); 193 | 194 | verify(() => mockRestApi.idToken = idToken); 195 | }); 196 | }); 197 | 198 | test('rootStore simply returns data as is', () { 199 | final sut = FirebaseDatabase.api(mockRestApi); 200 | 201 | expect( 202 | sut.rootStore.dataFromJson(const [1, true, '3']), 203 | const [1, true, '3'], 204 | ); 205 | expect( 206 | sut.rootStore.dataToJson(const [[], 1.5, null]), 207 | const [[], 1.5, null], 208 | ); 209 | expect( 210 | sut.rootStore.patchData( 211 | { 212 | 'a': 1, 213 | 'b': false, 214 | 'c': {'x': 1, 'y': 2}, 215 | }, 216 | const { 217 | 'b': null, 218 | 'c': [1, 2, 3], 219 | 'd': '42', 220 | }, 221 | ), 222 | const { 223 | 'a': 1, 224 | 'b': null, 225 | 'c': [1, 2, 3], 226 | 'd': '42', 227 | }, 228 | ); 229 | }); 230 | 231 | test('dispose cancels stream', () async { 232 | final mockStream = MockIdTokenStream(); 233 | final mockStreamSub = MockIdTokenStreamSub(); 234 | 235 | when(() => mockStreamSub.cancel()).thenAnswer((i) async {}); 236 | when(() => mockStream.listen( 237 | any(), 238 | onError: any(named: 'onError'), 239 | onDone: any(named: 'onDone'), 240 | cancelOnError: any(named: 'cancelOnError'), 241 | )).thenReturn(mockStreamSub); 242 | when(() => mockFirebaseAccount.idTokenStream) 243 | .thenAnswer((i) => mockStream); 244 | 245 | final sut = FirebaseDatabase.api( 246 | mockRestApi, 247 | account: mockFirebaseAccount, 248 | ); 249 | 250 | verify(() => mockStream.listen(any(), cancelOnError: false)); 251 | 252 | await sut.dispose(); 253 | 254 | verify(() => mockStreamSub.cancel()); 255 | }); 256 | 257 | test('createRootStore creates a typed variant', () { 258 | final sut = FirebaseDatabase.api(mockRestApi); 259 | 260 | final store = sut.createRootStore( 261 | onDataFromJson: (dynamic json) => (json as int) + 1, 262 | onDataToJson: (data) => data - 1, 263 | onPatchData: (data, updatedFields) => data * 2, 264 | ); 265 | 266 | expect(store.restApi, mockRestApi); 267 | expect(store.subPaths, isEmpty); 268 | 269 | expect(store.dataFromJson(10), 11); 270 | expect(store.dataToJson(10), 9); 271 | expect(store.patchData(10, const {}), 20); 272 | }); 273 | }); 274 | } 275 | -------------------------------------------------------------------------------- /test/unit/stream/js/sse_client_test_js.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:html'; 3 | 4 | import 'package:firebase_database_rest/src/stream/js/sse_client_js.dart'; 5 | import 'package:firebase_database_rest/src/stream/server_sent_event.dart'; 6 | import 'package:firebase_database_rest/src/stream/sse_client.dart'; 7 | import 'package:http/http.dart' as http; 8 | import 'package:mocktail/mocktail.dart'; 9 | import 'package:test/test.dart'; 10 | 11 | // ignore: avoid_implementing_value_types 12 | class FakeEvent extends Fake implements Event {} 13 | 14 | class MockClient extends Mock implements http.Client {} 15 | 16 | // ignore: avoid_implementing_value_types 17 | class MockErrorEvent extends Mock implements ErrorEvent {} 18 | 19 | // ignore: avoid_implementing_value_types 20 | class MockEventSource extends Mock implements EventSource {} 21 | 22 | class MockSSEClientJS extends Mock implements SSEClientJS {} 23 | 24 | class SutSSEClientJS extends SSEClientJS { 25 | final MockSSEClientJS mock; 26 | 27 | SutSSEClientJS(this.mock, http.Client client) : super(client); 28 | 29 | @override 30 | EventSource createEventSource(Uri url) => mock.createEventSource(url); 31 | } 32 | 33 | void setupTests() => group('SSEClientJS', () { 34 | final mockClient = MockClient(); 35 | final mockEventSource = MockEventSource(); 36 | final mockSSEClient = MockSSEClientJS(); 37 | 38 | late SutSSEClientJS clientSut; 39 | 40 | setUpAll(() { 41 | registerFallbackValue(Uri()); 42 | }); 43 | 44 | setUp(() { 45 | reset(mockClient); 46 | reset(mockEventSource); 47 | reset(mockSSEClient); 48 | 49 | // ignore: invalid_use_of_protected_member 50 | when(() => mockSSEClient.createEventSource(any())) 51 | .thenReturn(mockEventSource); 52 | 53 | clientSut = SutSSEClientJS(mockSSEClient, mockClient); 54 | }); 55 | 56 | test('default constructor creates SSEClientJS with http.Client', () { 57 | final client = SSEClient(); 58 | 59 | expect( 60 | client, 61 | isA().having( 62 | (c) => c.client, 63 | 'client', 64 | isNot(mockClient), 65 | ), 66 | ); 67 | }); 68 | 69 | test('proxy constructor creates SSEClientJS with given Client', () { 70 | final client = SSEClient.proxy(mockClient); 71 | 72 | expect( 73 | client, 74 | isA().having( 75 | (c) => c.client, 76 | 'client', 77 | mockClient, 78 | ), 79 | ); 80 | }); 81 | 82 | test('createEventSource create EventSource', () { 83 | final client = SSEClientJS(mockClient); 84 | final eventSource = 85 | // ignore: invalid_use_of_protected_member 86 | client.createEventSource(Uri.http('localhost', '/')); 87 | 88 | expect(eventSource, isA()); 89 | eventSource.close(); 90 | }); 91 | 92 | test('stream creates SSEStream instance', () async { 93 | final url = Uri(); 94 | final stream = await clientSut.stream(url); 95 | 96 | expect(stream, isA()); 97 | }); 98 | 99 | group('stream', () { 100 | final url = Uri.http('example.org', '/'); 101 | late SSEStream streamSut; 102 | 103 | setUp(() async { 104 | streamSut = await clientSut.stream(url); 105 | }); 106 | 107 | group('listen', () { 108 | test('creates event source when listening', () { 109 | streamSut.listen(null); 110 | 111 | // ignore: invalid_use_of_protected_member 112 | verify(() => mockSSEClient.createEventSource(url)); 113 | }); 114 | 115 | test('listens to error events', () { 116 | streamSut.listen(null); 117 | 118 | verify(() => mockEventSource.addEventListener('error', any())); 119 | }); 120 | 121 | test('emits generic SSEException on generic error events ', () { 122 | when( 123 | () => mockEventSource.addEventListener('error', any()), 124 | ).thenAnswer((i) { 125 | final cb = i.positionalArguments[1] as void Function(Event); 126 | cb(FakeEvent()); 127 | }); 128 | 129 | expect( 130 | streamSut, 131 | emitsError( 132 | isA().having( 133 | (e) => e.toString(), 134 | 'toString()', 135 | 'Unknown error', 136 | ), 137 | ), 138 | ); 139 | }); 140 | 141 | test('emits specific SSEException on explicit error events ', () { 142 | const message = 'error-message'; 143 | final mockEvent = MockErrorEvent(); 144 | 145 | when(() => mockEvent.message).thenReturn(message); 146 | when( 147 | () => mockEventSource.addEventListener('error', any()), 148 | ).thenAnswer((i) { 149 | final cb = i.positionalArguments[1] as void Function(Event); 150 | cb(mockEvent); 151 | }); 152 | 153 | expect( 154 | streamSut, 155 | emitsError( 156 | isA().having( 157 | (e) => e.toString(), 158 | 'toString()', 159 | message, 160 | ), 161 | ), 162 | ); 163 | }); 164 | 165 | test('adds no other event listeners by default', () { 166 | streamSut.listen(null); 167 | 168 | verifyNever( 169 | () => mockEventSource.addEventListener( 170 | any(that: isNot('error')), 171 | any(), 172 | ), 173 | ); 174 | }); 175 | 176 | test('add previously enabled event listeners', () { 177 | streamSut 178 | ..addEventType('A') 179 | ..addEventType('B') 180 | ..addEventType('C') 181 | ..removeEventType('B') 182 | ..addEventType('D') 183 | ..listen(null); 184 | 185 | verify(() => mockEventSource.addEventListener('error', any())); 186 | verify(() => mockEventSource.addEventListener('A', any())); 187 | verify(() => mockEventSource.addEventListener('C', any())); 188 | verify(() => mockEventSource.addEventListener('D', any())); 189 | verifyNever(() => mockEventSource.addEventListener(any(), any())); 190 | }); 191 | }); 192 | 193 | group('simple sub', () { 194 | late StreamSubscription subSut; 195 | 196 | setUp(() { 197 | subSut = streamSut.listen(null); 198 | }); 199 | 200 | test('cancel closes event source', () async { 201 | await subSut.cancel(); 202 | 203 | verify(() => mockEventSource.close()); 204 | }); 205 | 206 | group('addEventType', () { 207 | test('immediatly adds event listener', () { 208 | const type = 'X'; 209 | streamSut.addEventType(type); 210 | 211 | verify(() => mockEventSource.addEventListener(type, any())); 212 | }); 213 | 214 | test('adds event listener only once', () { 215 | const type = 'X'; 216 | streamSut..addEventType(type)..addEventType(type); 217 | 218 | verify( 219 | () => mockEventSource.addEventListener(type, any()), 220 | ).called(1); 221 | }); 222 | }); 223 | 224 | group('removeEventType', () { 225 | setUp(() { 226 | streamSut.addEventType('R'); 227 | }); 228 | 229 | test('does nothing if not registered', () { 230 | final res = streamSut.removeEventType('T'); 231 | 232 | expect(res, isFalse); 233 | 234 | verifyNever( 235 | () => mockEventSource.removeEventListener(any(), any()), 236 | ); 237 | }); 238 | 239 | test('immediatly removes event listener', () { 240 | final res = streamSut.removeEventType('R'); 241 | 242 | expect(res, isTrue); 243 | 244 | verify(() => mockEventSource.removeEventListener('R', any())); 245 | }); 246 | 247 | test('removes event listener only once', () { 248 | final res1 = streamSut.removeEventType('R'); 249 | final res2 = streamSut.removeEventType('R'); 250 | 251 | expect(res1, isTrue); 252 | expect(res2, isFalse); 253 | 254 | verify( 255 | () => mockEventSource.removeEventListener('R', any()), 256 | ).called(1); 257 | }); 258 | 259 | test('removes same handler as the one that was registered', () { 260 | streamSut.addEventType('L'); 261 | 262 | final handler = verify( 263 | () => mockEventSource.addEventListener('L', captureAny())) 264 | .captured 265 | .single as dynamic Function(Event)?; 266 | 267 | streamSut.removeEventType('L'); 268 | 269 | verify(() => mockEventSource.removeEventListener('L', handler)); 270 | }); 271 | }); 272 | }); 273 | 274 | group('onData', () { 275 | test('forwards added events', () { 276 | const eventType = 'test-event'; 277 | final events = [ 278 | MessageEvent(eventType, data: 'event1', lastEventId: '1'), 279 | MessageEvent('message', data: 'event2'), 280 | MessageEvent('whatever', data: 'event3', lastEventId: '10'), 281 | ]; 282 | 283 | when(() => mockEventSource.addEventListener(eventType, any())) 284 | .thenAnswer((i) { 285 | final handler = 286 | i.positionalArguments[1] as dynamic Function(Event); 287 | for (final event in events) { 288 | handler(event); 289 | } 290 | }); 291 | 292 | streamSut.addEventType(eventType); 293 | expect( 294 | streamSut, 295 | emitsInOrder(const [ 296 | ServerSentEvent( 297 | event: eventType, 298 | data: 'event1', 299 | lastEventId: '1', 300 | ), 301 | ServerSentEvent( 302 | event: eventType, 303 | data: 'event2', 304 | ), 305 | ServerSentEvent( 306 | event: eventType, 307 | data: 'event3', 308 | lastEventId: '10', 309 | ), 310 | ]), 311 | ); 312 | }); 313 | 314 | test('ignores events when paused', () async { 315 | final data = []; 316 | final sub = (streamSut..addEventType('message')).listen(data.add); 317 | 318 | final handler = verify(() => 319 | mockEventSource.addEventListener('message', captureAny())) 320 | .captured 321 | .single as dynamic Function(Event); 322 | 323 | expect(data, isEmpty); 324 | 325 | handler(MessageEvent('message', data: 'data1')); 326 | await Future.delayed(const Duration(milliseconds: 500)); 327 | expect(data, const [ServerSentEvent(data: 'data1')]); 328 | 329 | sub.pause(); 330 | 331 | handler(MessageEvent('message', data: 'data2')); 332 | await Future.delayed(const Duration(milliseconds: 500)); 333 | expect(data, const [ServerSentEvent(data: 'data1')]); 334 | 335 | sub.resume(); 336 | 337 | handler(MessageEvent('message', data: 'data3')); 338 | await Future.delayed(const Duration(milliseconds: 500)); 339 | expect(data, const [ 340 | ServerSentEvent(data: 'data1'), 341 | ServerSentEvent(data: 'data3'), 342 | ]); 343 | 344 | await sub.cancel(); 345 | }); 346 | }); 347 | }); 348 | }); 349 | -------------------------------------------------------------------------------- /lib/src/rest/rest_api.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:http/http.dart'; 4 | import 'package:path/path.dart'; 5 | 6 | import '../common/api_constants.dart'; 7 | import '../common/db_exception.dart'; 8 | import '../common/filter.dart'; 9 | import '../common/timeout.dart'; 10 | import '../stream/sse_client.dart'; 11 | import 'models/db_response.dart'; 12 | import 'models/stream_event.dart'; 13 | import 'stream_event_transformer.dart'; 14 | 15 | /// A class to communicated with the firebase realtime database REST-API 16 | /// 17 | /// Many methods of this class accept an `eTag` parameter. The Firebase ETag is 18 | /// the unique identifier for the current data at a specified location. If the 19 | /// data changes at that location, the ETag changes, too. If set, the class 20 | /// will request this eTag and return in via [DbResponse.eTag]. 21 | class RestApi { 22 | /// The HTTP-Client that should be used to send requests. 23 | final SSEClient client; 24 | 25 | /// The name of the database to connect to. 26 | final String database; 27 | 28 | /// A sub path in the database to use as virtual root path. 29 | final String basePath; 30 | 31 | /// The idToken to use for requests. 32 | /// 33 | /// If set to null, all requests will be unauthenticated. If set, all requests 34 | /// will add the auth parameter and thus be authenticated. 35 | String? idToken; 36 | 37 | /// The timeout for read requests. See [Timeout]. 38 | Timeout? timeout; 39 | 40 | /// The limit for how big write requests can be. See [WriteSizeLimit]. 41 | WriteSizeLimit? writeSizeLimit; 42 | 43 | /// Default constructor. 44 | /// 45 | /// If [client] is as [SSEClient], it is directly used as [this.client]. 46 | /// Otherwise, [SSEClient.proxy] is used to create a [SSEClient] from the 47 | /// given simple [Client]. 48 | RestApi({ 49 | required Client client, 50 | required this.database, 51 | this.basePath = '', 52 | this.idToken, 53 | this.timeout, 54 | this.writeSizeLimit, 55 | }) : client = client is SSEClient ? client : SSEClient.proxy(client); 56 | 57 | /// Sends a get requests to the database to read some data. 58 | /// 59 | /// Tries to read the data at [path], or the whole virtual root, if not set. 60 | /// The [printMode] and [formatMode] can be used to control how data is 61 | /// formatted by the server. 62 | /// 63 | /// The [shallow] parameter can be used to help you work with large datasets 64 | /// without needing to download everything. Set this to true to limit the 65 | /// depth of the data returned at a location. If the data at the location is 66 | /// a JSON primitive (string, number or boolean), its value will simply be 67 | /// returned. If the data snapshot at the location is a JSON object, the 68 | /// values for each key will be truncated to true. 69 | /// 70 | /// If [filter] is added, that filter will be applied to filter the results 71 | /// returned by the server. See [Filter] for more details. 72 | /// 73 | /// Finally, the [eTag] parameter can be set to true to request an eTag for 74 | /// the given data. 75 | Future get({ 76 | String? path, 77 | PrintMode? printMode, 78 | FormatMode? formatMode, 79 | bool? shallow, 80 | Filter? filter, 81 | bool eTag = false, 82 | }) async { 83 | final response = await client.get( 84 | _buildUri( 85 | path: path, 86 | filter: filter, 87 | printMode: printMode, 88 | formatMode: formatMode, 89 | shallow: shallow, 90 | ), 91 | headers: _buildHeaders( 92 | eTag: eTag, 93 | ), 94 | ); 95 | return _parseResponse(response, eTag); 96 | } 97 | 98 | /// Sends a post requests to the database to create new data. 99 | /// 100 | /// Tries to create a new child entry at [path], or the whole virtual root, 101 | /// if not set. The target location must be an object or array for this to 102 | /// work. If posting to an array, the [body] is simply appended. For objects, 103 | /// a new random key is generated and the [body] added to the object under 104 | /// that key. In either cases, the returned result contains the id of the 105 | /// newly created entry. 106 | /// 107 | /// The [printMode] can be used to control how data is formatted by the 108 | /// server. 109 | /// 110 | /// Finally, the [eTag] parameter can be set to true to request an eTag for 111 | /// the given data. 112 | Future post( 113 | dynamic body, { 114 | String? path, 115 | PrintMode? printMode, 116 | bool eTag = false, 117 | }) async { 118 | final response = await client.post( 119 | _buildUri( 120 | path: path, 121 | printMode: printMode, 122 | ), 123 | body: json.encode(body), 124 | headers: _buildHeaders( 125 | hasBody: true, 126 | eTag: eTag, 127 | ), 128 | ); 129 | return _parseResponse(response, eTag); 130 | } 131 | 132 | /// Sends a put requests to the database to write some data. 133 | /// 134 | /// Tries to write the [body] to [path], or to the virtual root, if not set. 135 | /// The [printMode] can be used to control how data is formatted by the 136 | /// server. 137 | /// 138 | /// Finally, the [eTag] parameter can be set to true to request an eTag for 139 | /// the given data. If you only want to be able to write data if it was not 140 | /// changed, pass a previously acquired eTag as [ifMatch]. In case the eTag 141 | /// does not match, the request will fail with the 142 | /// [ApiConstants.statusCodeETagMismatch] status code. To only write data that 143 | /// does not exist yet, use [ApiConstants.nullETag] as value for [ifMatch]. 144 | Future put( 145 | dynamic body, { 146 | String? path, 147 | PrintMode? printMode, 148 | bool eTag = false, 149 | String? ifMatch, 150 | }) async { 151 | final response = await client.put( 152 | _buildUri( 153 | path: path, 154 | printMode: printMode, 155 | ), 156 | body: json.encode(body), 157 | headers: _buildHeaders( 158 | hasBody: true, 159 | eTag: eTag, 160 | ifMatch: ifMatch, 161 | ), 162 | ); 163 | return _parseResponse(response, eTag); 164 | } 165 | 166 | /// Sends a patch requests to the database to update some data. 167 | /// 168 | /// Tries to update the given fields of [updateChildren] to their new values 169 | /// at [path], or to the virtual root, if not set. Only fields explicitly 170 | /// specified are updated, all other fields are not modified. To delete a 171 | /// field, set the update value to `null`. 172 | /// 173 | /// The [printMode] can be used to control how data is formatted by the 174 | /// server. 175 | Future patch( 176 | Map updateChildren, { 177 | String? path, 178 | PrintMode? printMode, 179 | }) async { 180 | final response = await client.patch( 181 | _buildUri( 182 | path: path, 183 | printMode: printMode, 184 | ), 185 | body: json.encode(updateChildren), 186 | headers: _buildHeaders( 187 | hasBody: true, 188 | ), 189 | ); 190 | return _parseResponse(response, false); 191 | } 192 | 193 | /// Sends a delete requests to the database to delete some data. 194 | /// 195 | /// Tries to delete the data at [path], or the whole virtual root, if not set. 196 | /// The [printMode] can be used to control how data is formatted by the 197 | /// server. 198 | /// 199 | /// Finally, the [eTag] parameter can be set to true to request an eTag for 200 | /// the given data. The resulting eTag should always be 201 | /// [ApiConstants.nullETag]. If you only want to be able to delete data if it 202 | /// was not changed, pass a previously acquired eTag as [ifMatch]. In case 203 | /// the eTag does not match, the request will fail with the 204 | /// [ApiConstants.statusCodeETagMismatch] status code. 205 | Future delete({ 206 | String? path, 207 | PrintMode? printMode, 208 | bool eTag = false, 209 | String? ifMatch, 210 | }) async { 211 | final response = await client.delete( 212 | _buildUri( 213 | path: path, 214 | printMode: printMode, 215 | ), 216 | headers: _buildHeaders( 217 | eTag: eTag, 218 | ifMatch: ifMatch, 219 | ), 220 | ); 221 | return _parseResponse(response, eTag); 222 | } 223 | 224 | /// Sends a get requests to the database to stream changes. 225 | /// 226 | /// Tries to stream the data at [path], or the whole virtual root, if not set. 227 | /// The [printMode] and [formatMode] can be used to control how data is 228 | /// formatted by the server. 229 | /// 230 | /// The resulting future will stream various [StreamEvent]s, which provide 231 | /// realtime information about how data is changed in the database. Please 232 | /// note that the first element of every stream will be a [StreamEvent.put] 233 | /// with the current state of the database at [path]. All events after that 234 | /// are fired as data is manipulated in the database. 235 | /// 236 | /// The [shallow] parameter can be used to help you work with large datasets 237 | /// without needing to download everything. Set this to true to limit the 238 | /// depth of the data returned at a location. If the data at the location is 239 | /// a JSON primitive (string, number or boolean), its value will simply be 240 | /// returned. If the data snapshot at the location is a JSON object, the 241 | /// values for each key will be truncated to true. 242 | /// 243 | /// If [filter] is added, that filter will be applied to filter the results 244 | /// returned by the server. See [Filter] for more details. 245 | Future> stream({ 246 | String? path, 247 | PrintMode? printMode, 248 | FormatMode? formatMode, 249 | bool? shallow, 250 | Filter? filter, 251 | }) async { 252 | final source = await client.stream( 253 | _buildUri( 254 | path: path, 255 | filter: filter, 256 | printMode: printMode, 257 | formatMode: formatMode, 258 | shallow: shallow, 259 | ), 260 | ); 261 | for (final eventType in StreamEventTransformer.eventTypes) { 262 | source.addEventType(eventType); 263 | } 264 | return source.transform(const StreamEventTransformer()); 265 | } 266 | 267 | Uri _buildUri({ 268 | String? path, 269 | Filter? filter, 270 | PrintMode? printMode, 271 | FormatMode? formatMode, 272 | bool? shallow, 273 | }) { 274 | final uri = Uri( 275 | scheme: 'https', 276 | host: '$database.firebaseio.com', 277 | path: posix.normalize( 278 | path != null ? '$basePath/$path.json' : '$basePath.json', 279 | ), 280 | queryParameters: { 281 | if (idToken != null) 'auth': idToken!, 282 | if (timeout != null) 'timeout': timeout!.toString(), 283 | if (writeSizeLimit != null) 'writeSizeLimit': writeSizeLimit!.value, 284 | if (printMode != null) 'print': printMode.value, 285 | if (formatMode != null) 'format': formatMode.value, 286 | if (shallow != null) 'shallow': shallow.toString(), 287 | ...?filter?.filters, 288 | }, 289 | ); 290 | return uri; 291 | } 292 | 293 | Map _buildHeaders({ 294 | bool hasBody = false, 295 | bool eTag = false, 296 | String? ifMatch, 297 | String? accept, 298 | }) { 299 | final headers = { 300 | 'Accept': accept ?? 'application/json', 301 | if (hasBody) 'Content-Type': 'application/json', 302 | if (eTag) 'X-Firebase-ETag': 'true', 303 | if (ifMatch != null) 'if-match': ifMatch, 304 | }; 305 | return headers; 306 | } 307 | 308 | DbResponse _parseResponse( 309 | Response response, 310 | bool eTag, 311 | ) { 312 | final tag = eTag ? response.headers['etag'] : null; 313 | if (response.statusCode == ApiConstants.statusCodeETagMismatch) { 314 | throw const DbException(statusCode: ApiConstants.statusCodeETagMismatch); 315 | } else if (response.statusCode >= 300) { 316 | throw DbException.fromJson( 317 | json.decode(response.body) as Map, 318 | ).copyWith(statusCode: response.statusCode); 319 | } else if (response.statusCode == 204) { 320 | return DbResponse( 321 | data: null, 322 | eTag: tag, 323 | ); 324 | } else { 325 | return DbResponse( 326 | data: json.decode(response.body), 327 | eTag: tag, 328 | ); 329 | } 330 | } 331 | } 332 | --------------------------------------------------------------------------------