diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 22cc9200d..93dbf2203 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,13 @@ -Describe the purpose of the pull request. +There are some simple steps to get your PR merged, that are the following: + +- Describe your PR and why a maintainer should merge it; +- Put the same description inside the commit body otherwise if we change github hosting you application will be based on a instable source code; +- Write the commit header in the way that it is following these simple rules [1] +- Make sure that your PR will pass the CI +- Wait for an review :smile: that will not happen if one of the previous step is missing. + +[1](https://github.com/zino-hofmann/graphql-flutter/blob/main/docs/dev/MAINTAINERS.md#commit-style) + \ No newline at end of file +--> diff --git a/examples/starwars/pubspec.yaml b/examples/starwars/pubspec.yaml index c1a6dceef..76a56404f 100644 --- a/examples/starwars/pubspec.yaml +++ b/examples/starwars/pubspec.yaml @@ -4,7 +4,7 @@ description: An example graphql_flutter application utilizing graphql_starwars_t publish_to: none environment: - sdk: ">=2.13.0 <=3.0.0" + sdk: ">=2.13.0 <4.0.0" dependencies: flutter: diff --git a/examples/starwars/server/pubspec.yaml b/examples/starwars/server/pubspec.yaml index 63a9e1669..ee0da3de2 100644 --- a/examples/starwars/server/pubspec.yaml +++ b/examples/starwars/server/pubspec.yaml @@ -3,7 +3,7 @@ name: starwars_server publish_to: none environment: - sdk: ">=2.10.0 <3.0.0" + sdk: ">=2.12.0 <4.0.0" dependencies: graphql_starwars_test_server: ^0.1.0 diff --git a/packages/graphql/CHANGELOG.md b/packages/graphql/CHANGELOG.md index 83fd22e5b..ee42da9ce 100644 --- a/packages/graphql/CHANGELOG.md +++ b/packages/graphql/CHANGELOG.md @@ -1,3 +1,37 @@ +# v5.2.0-beta.7 + +## Fixed +- bump uuid dependency to ^4.0.0 ([commit](https://github.com/zino-hofmann/graphql-flutter/commit/4fa6dd61c7fb9aad806df70a318cfd1086e35e68)). @francescoberardi 23-11-2023 + + +# v5.2.0-beta.6 + +## Fixed +- Send SubscriptionComplete message when using graphqlTra… ([commit](https://github.com/zino-hofmann/graphql-flutter/commit/6e73d62ba2b8a58a35b3b18e372003462a73e192)). @ 30-08-2023 + +## Added +- Send custom payload in PingMessage ([commit](https://github.com/zino-hofmann/graphql-flutter/commit/19d5c86b98e889b333996da43126f9404a9a4556)). @Rochak69 31-08-2023 +- added WebSocket token refresh and autoReconnect toggling ([commit](https://github.com/zino-hofmann/graphql-flutter/commit/e1c6d5404be2ff54f916bceab6bb52a04bae5d01)). @vytautas-pranskunas- 24-07-2023 + + +# v5.2.0-beta.5 + +## Fixed +- bumps http to v1 ([commit](https://github.com/zino-hofmann/graphql-flutter/commit/396c3b3f6986b6d3174e548982a93188b49ee5bc)). @moisessantos 06-07-2023 + + +# v5.2.0-beta.4 + +## Added +- Cache parsed data ([commit](https://github.com/zino-hofmann/graphql-flutter/commit/aa81251f71f7a5f566eae4a9575eb6547050c2d9)). @budde377 03-06-2023 + + +# v5.2.0-beta.3 + +## Added +- bump sdk version upper bound to <4.0.0 ([commit](https://github.com/zino-hofmann/graphql-flutter/commit/8bb9ba355e53dccf5e291b1f05171459bf8867ed)). @ndelanou 17-05-2023 + + # v5.2.0-beta.2 ## Added diff --git a/packages/graphql/README.md b/packages/graphql/README.md index 533143387..333bace91 100644 --- a/packages/graphql/README.md +++ b/packages/graphql/README.md @@ -128,7 +128,7 @@ which requires a few changes to the above: > **NB**: This is different in `graphql_flutter`, which provides `await initHiveForFlutter()` for initialization in `main` ```dart -GraphQL getClient() async { +GraphQLClient getClient() async { ... /// initialize Hive and wrap the default box in a HiveStore final store = await HiveStore.open(path: 'my/cache/path'); @@ -327,6 +327,87 @@ subscription = client.subscribe( subscription.listen(reactToAddedReview) ``` +#### Adding headers (including auth) to WebSocket + +In order to add auth header or any other header to websocket connection use `initialPayload` property + +```dart +initialPayload: () { + var headers = {}; + headers.putIfAbsent(HttpHeaders.authorizationHeader, () => token); + + return headers; +}, +``` + +#### Refreshing headers (including auth) + +In order to refresh auth header you need to setup `onConnectionLost` function + +```dart +onConnectionLost: (int? code, String? reason) async { + if (code == 4001) { + await authTokenService.issueToken(refresh: true); + return Duration.zero; + } + + return null; + } +``` + +Where `code` and `reason` are values returned from the server on connection close. There is no such code like 401 in WebSockets so you can use your custom and server code could look similar: + +```typescript +subscriptions: { + 'graphql-ws': { + onConnect: async (context: any) => { + const { connectionParams } = context; + + if (!connectionParams) { + throw new Error('Connection params are missing'); + } + + const authToken = connectionParams.authorization; + + if (authToken) { + const isValid await authService.isTokenValid(authToken); + + if (!isValid) { + context.extra.socket.close(4001, 'Unauthorized'); + } + + return; + } + }, + }, +}, +``` + +`onConnectionLost` function returns `Duration` which is basically `delayBetweenReconnectionAttempts` for current reconnect attempt. If duration is `null` then default `delayBetweenReconnectionAttempts` will be used. Otherwise returned value. For example upon expired auth token there is not much sense to wait after token is refreshed. + +#### Handling connection manually + +`toggleConnection` stream was introduced to allow connect or disconnect manually. + +```dart +var toggleConnection = PublishSubject; + +SocketClientConfig( + toggleConnection: toggleConnection, +), +``` + +later from your code call + +```dart +toggleConnection.add(ToggleConnectionState.disconnect); +//OR +toggleConnection.add(ToggleConnectionState.connect); +``` + +When `disconnect` event is called `autoReconnect` stops. When `connect` is called `autoReconnect` resumes. +this is useful when for some reason you want to stop reconnection. For example when user logouts from the system and reconnection would cause auth error from server causing infinite loop. + #### Customizing WebSocket Connections `WebSocketLink` now has an experimental `connect` parameter that can be @@ -427,7 +508,7 @@ class _Connection { ``` -2- if you need to update your socket just cancel your subscription and resubscribe again using usual way +2- if you need to update your socket just cancel your subscription and resubscribe again using usual way and if the token changed it will be reconnect with the new token otherwise it will use the same client @@ -435,7 +516,7 @@ and if the token changed it will be reconnect with the new token otherwise it wi ### `client.watchQuery` and `ObservableQuery` [`client.watchQuery`](https://pub.dev/documentation/graphql/latest/graphql/GraphQLClient/watchQuery.html) -can be used to execute both queries and mutations, then reactively listen to changes to the underlying data in the cache. +can be used to execute both queries and mutations, then reactively listen to changes to the underlying data in the cache. ```dart final observableQuery = client.watchQuery( @@ -506,7 +587,7 @@ To disable cache normalization entirely, you could pass `(data) => null`. If you only cared about `nodeId`, you could pass `(data) => data['nodeId']`. Here's a more detailed example where the system involved contains versioned entities you don't want to clobber: -```dart +```dart String customDataIdFromObject(Map data) { final typeName = data['__typename']; final entityId = data['entityId']; @@ -589,7 +670,7 @@ query { ``` -if you're not providing the possible type map and introspecting the typename, the cache can't be updated. +if you're not providing the possible type map and introspecting the typename, the cache can't be updated. ## Direct Cache Access API @@ -597,9 +678,9 @@ The [`GraphQLCache`](https://pub.dev/documentation/graphql/latest/graphql/GraphQ leverages [`normalize`] to give us a fairly apollo-ish [direct cache access] API, which is also available on `GraphQLClient`. This means we can do [local state management] in a similar fashion as well. -The cache access methods are available on any cache proxy, which includes the `GraphQLCache` the `OptimisticProxy` passed to `update` in the `graphql_flutter` `Mutation` widget, and the `client` itself. +The cache access methods are available on any cache proxy, which includes the `GraphQLCache` the `OptimisticProxy` passed to `update` in the `graphql_flutter` `Mutation` widget, and the `client` itself. > **NB** counter-intuitively, you likely never want to use use direct cache access methods directly on the `cache`, -> as they will not be rebroadcast automatically. +> as they will not be rebroadcast automatically. > **Prefer `client.writeQuery` and `client.writeFragment` to those on the `client.cache` for automatic rebroadcasting** In addition to this overview, a complete and well-commented rundown of can be found in the @@ -641,10 +722,10 @@ final data = client.readQuery(queryRequest); client.writeQuery(queryRequest, data); ``` -The cache access methods are available on any cache proxy, which includes the `GraphQLCache` the `OptimisticProxy` passed to `update` in the `graphql_flutter` `Mutation` widget, and the `client` itself. -> **NB** counter-intuitively, you likely never want to use use direct cache access methods on the cache +The cache access methods are available on any cache proxy, which includes the `GraphQLCache` the `OptimisticProxy` passed to `update` in the `graphql_flutter` `Mutation` widget, and the `client` itself. +> **NB** counter-intuitively, you likely never want to use use direct cache access methods on the cache cache.readQuery(queryRequest); -client.readQuery(queryRequest); // +client.readQuery(queryRequest); // ### `FragmentRequest`, `readFragment`, and `writeFragment` `FragmentRequest` has almost the same api as `Request`, but is provided directly from `graphql` for consistency. @@ -710,7 +791,7 @@ client.query(QueryOptions( errorPolicy: ErrorPolicy.ignore, // ignore cache data. cacheRereadPolicy: CacheRereadPolicy.ignore, - // ... + // ... )); ``` Defaults can also be overridden via `defaultPolices` on the client itself: @@ -724,11 +805,11 @@ GraphQLClient( CacheRereadPolicy.mergeOptimistic, ), ), - // ... + // ... ) ``` -**[`FetchPolicy`](https://pub.dev/documentation/graphql/latest/graphql/FetchPolicy-class.html):** determines where the client may return a result from, and whether that result will be saved to the cache. +**[`FetchPolicy`](https://pub.dev/documentation/graphql/latest/graphql/FetchPolicy-class.html):** determines where the client may return a result from, and whether that result will be saved to the cache. Possible options: - cacheFirst: return result from cache. Only fetch from network if cached result is not available. @@ -737,7 +818,7 @@ Possible options: - noCache: return result from network, fail if network call doesn't succeed, don't save to cache. - networkOnly: return result from network, fail if network call doesn't succeed, save to cache. -**[`ErrorPolicy`](https://pub.dev/documentation/graphql/latest/graphql/ErrorPolicy-class.html):** determines the level of events for errors in the execution result. +**[`ErrorPolicy`](https://pub.dev/documentation/graphql/latest/graphql/ErrorPolicy-class.html):** determines the level of events for errors in the execution result. Possible options: - none (default): Any GraphQL Errors are treated the same as network errors and any data is ignored from the response. @@ -869,7 +950,7 @@ API key, IAM, and Federated provider authorization could be accomplished through This package does not support code-generation out of the box, but [graphql_codegen](https://pub.dev/packages/graphql_codegen) does! -This package extensions on the client which takes away the struggle of serialization and gives you confidence through type-safety. +This package extensions on the client which takes away the struggle of serialization and gives you confidence through type-safety. It is also more performant than parsing GraphQL queries at runtime. For example, by creating the `.graphql` file @@ -966,3 +1047,4 @@ HttpLink httpLink = HttpLink('https://api.url/graphql', defaultHeaders: { [local state management]: https://www.apollographql.com/docs/tutorial/local-state/#update-local-data [`typepolicies`]: https://www.apollographql.com/docs/react/caching/cache-configuration/#the-typepolicy-type [direct cache access]: https://www.apollographql.com/docs/react/caching/cache-interaction/ + diff --git a/packages/graphql/changelog.json b/packages/graphql/changelog.json index 56266ccef..1bf898e4b 100644 --- a/packages/graphql/changelog.json +++ b/packages/graphql/changelog.json @@ -1,6 +1,6 @@ { "package_name": "graphql", - "version": "v5.2.0-beta.2", + "version": "v5.2.0-beta.7", "api": { "name": "github", "repository": "zino-hofmann/graphql-flutter", diff --git a/packages/graphql/example/pubspec.yaml b/packages/graphql/example/pubspec.yaml index 24a215420..0c9f93135 100644 --- a/packages/graphql/example/pubspec.yaml +++ b/packages/graphql/example/pubspec.yaml @@ -5,7 +5,7 @@ version: 1.0.0+1 publish_to: none environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.12.0 <4.0.0' dependencies: args: diff --git a/packages/graphql/lib/src/core/fetch_more.dart b/packages/graphql/lib/src/core/fetch_more.dart index 10c1a8fec..915c52027 100644 --- a/packages/graphql/lib/src/core/fetch_more.dart +++ b/packages/graphql/lib/src/core/fetch_more.dart @@ -34,11 +34,11 @@ Future> fetchMoreImplementation( final data = fetchMoreOptions.updateQuery( previousResult.data, fetchMoreResult.data, - )!; + ); fetchMoreResult.data = data; - if (originalOptions.fetchPolicy != FetchPolicy.noCache) { + if (originalOptions.fetchPolicy != FetchPolicy.noCache && data != null) { queryManager.attemptCacheWriteFromClient( request, data, diff --git a/packages/graphql/lib/src/core/query_result.dart b/packages/graphql/lib/src/core/query_result.dart index 79fe9677b..5b5b5b38f 100644 --- a/packages/graphql/lib/src/core/query_result.dart +++ b/packages/graphql/lib/src/core/query_result.dart @@ -43,12 +43,14 @@ final _eagerSources = { class QueryResult { @protected QueryResult.internal({ - this.data, + Map? data, this.exception, this.context = const Context(), required this.parserFn, required this.source, - }) : timestamp = DateTime.now(); + }) : timestamp = DateTime.now() { + _data = data; + } factory QueryResult({ required BaseOptions options, @@ -99,7 +101,17 @@ class QueryResult { QueryResultSource? source; /// Response data - Map? data; + Map? get data { + return _data; + } + + set data(Map? data) { + _data = data; + _cachedParsedData = null; + } + + Map? _data; + TParsed? _cachedParsedData; /// Response context. Defaults to an empty `Context()` Context context; @@ -137,11 +149,15 @@ class QueryResult { TParsed? get parsedData { final data = this.data; final parserFn = this.parserFn; + final cachedParsedData = _cachedParsedData; if (data == null) { return null; } - return parserFn(data); + if (cachedParsedData != null) { + return cachedParsedData; + } + return _cachedParsedData = parserFn(data); } @override diff --git a/packages/graphql/lib/src/links/websocket_link/websocket_client.dart b/packages/graphql/lib/src/links/websocket_link/websocket_client.dart index ccfe3dc2f..9f6ff4701 100644 --- a/packages/graphql/lib/src/links/websocket_link/websocket_client.dart +++ b/packages/graphql/lib/src/links/websocket_link/websocket_client.dart @@ -10,8 +10,9 @@ import 'package:graphql/src/utilities/platform.dart'; import 'package:meta/meta.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_channel/stream_channel.dart'; +import 'package:uuid/data.dart'; +import 'package:uuid/rng.dart'; import 'package:uuid/uuid.dart'; -import 'package:uuid/uuid_util.dart'; import 'package:web_socket_channel/status.dart' as ws_status; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -26,7 +27,7 @@ typedef WebSocketConnect = FutureOr Function( ); // create uuid generator -final _uuid = Uuid(options: {'grng': UuidUtil.cryptoRNG}); +final _uuid = Uuid(goptions: GlobalOptions(CryptoRNG())); class SubscriptionListener { Function callback; @@ -37,18 +38,22 @@ class SubscriptionListener { enum SocketConnectionState { notConnected, handshake, connecting, connected } +enum ToggleConnectionState { disconnect, connect } + class SocketClientConfig { - const SocketClientConfig({ - this.serializer = const RequestSerializer(), - this.parser = const ResponseParser(), - this.autoReconnect = true, - this.queryAndMutationTimeout = const Duration(seconds: 10), - this.inactivityTimeout = const Duration(seconds: 30), - this.delayBetweenReconnectionAttempts = const Duration(seconds: 5), - this.initialPayload, - this.headers, - this.connectFn, - }); + const SocketClientConfig( + {this.serializer = const RequestSerializer(), + this.parser = const ResponseParser(), + this.autoReconnect = true, + this.queryAndMutationTimeout = const Duration(seconds: 10), + this.inactivityTimeout = const Duration(seconds: 30), + this.delayBetweenReconnectionAttempts = const Duration(seconds: 5), + this.initialPayload, + this.headers, + this.connectFn, + this.onConnectionLost, + this.toggleConnection, + this.pingMessage = const {}}); /// Serializer used to serialize request final RequestSerializer serializer; @@ -72,6 +77,9 @@ class SocketClientConfig { /// If null, the reconnection will occur immediately, although not recommended. final Duration? delayBetweenReconnectionAttempts; + // The payload to send the send while pinging. If null payload while ping the server will be empty. + final Map pingMessage; + /// The duration after which a query or mutation should time out. /// If null, no timeout is applied, although not recommended. final Duration? queryAndMutationTimeout; @@ -98,6 +106,11 @@ class SocketClientConfig { /// Custom header to add inside the client final Map? headers; + final Future? Function(int? code, String? reason)? + onConnectionLost; + + final Stream? toggleConnection; + /// Function to define another connection without call directly /// the connection function FutureOr connect( @@ -192,6 +205,7 @@ class SocketClient { @visibleForTesting this.onMessage, @visibleForTesting this.onStreamError = _defaultOnStreamError, }) { + _listenToToggleConnection(); _connect(); } @@ -232,6 +246,30 @@ class SocketClient { Response Function(Map) get parse => config.parser.parseResponse; + bool _isReconnectionPaused = false; + final _unsubscriber = PublishSubject(); + + void _listenToToggleConnection() { + if (config.toggleConnection != null) { + config.toggleConnection! + .where((_) => !_connectionStateController.isClosed) + .takeUntil(_unsubscriber) + .listen((event) { + if (event == ToggleConnectionState.disconnect && + _connectionStateController.value == + SocketConnectionState.connected) { + _isReconnectionPaused = true; + onConnectionLost(); + } else if (event == ToggleConnectionState.connect && + _connectionStateController.value == + SocketConnectionState.notConnected) { + _isReconnectionPaused = false; + _connect(); + } + }); + } + } + void _disconnectOnKeepAliveTimeout(Stream messages) { _keepAliveSubscription = messages.whereType().timeout( config.inactivityTimeout!, @@ -334,6 +372,9 @@ class SocketClient { } void onConnectionLost([Object? e]) async { + var code = socketChannel?.closeCode; + var reason = socketChannel?.closeReason; + await _closeSocketChannel(); if (e != null) { print('There was an error causing connection lost: $e'); @@ -344,6 +385,7 @@ class SocketClient { _keepAliveSubscription?.cancel(); _messageSubscription?.cancel(); + //TODO: do we really need this check here because few lines bellow there is another check if (_connectionStateController.isClosed || _wasDisposed) { return; } @@ -351,27 +393,31 @@ class SocketClient { _connectionWasLost = true; _subscriptionInitializers.values.forEach((s) => s.hasBeenTriggered = false); - if (config.autoReconnect && - !_connectionStateController.isClosed && - !_wasDisposed) { - if (config.delayBetweenReconnectionAttempts != null) { - _reconnectTimer = Timer( - config.delayBetweenReconnectionAttempts!, - () { - _connect(); - }, - ); - } else { - Timer.run(() => _connect()); - } + if (_isReconnectionPaused || + !config.autoReconnect || + _connectionStateController.isClosed || + _wasDisposed) { + return; } + + var duration = config.delayBetweenReconnectionAttempts ?? Duration.zero; + if (config.onConnectionLost != null) { + duration = (await config.onConnectionLost!(code, reason)) ?? duration; + } + + _reconnectTimer = Timer( + duration, + () async { + _connect(); + }, + ); } void _enqueuePing() { _pingTimer?.cancel(); _pingTimer = new Timer( config.inactivityTimeout!, - () => _write(PingMessage()), + () => _write(PingMessage(config.pingMessage)), ); } @@ -389,6 +435,7 @@ class SocketClient { _reconnectTimer?.cancel(); _pingTimer?.cancel(); _keepAliveSubscription?.cancel(); + _unsubscriber.close(); await Future.wait([ _closeSocketChannel(), @@ -426,10 +473,8 @@ class SocketClient { final bool waitForConnection, ) { final String id = _uuid.v4( - options: { - 'random': randomBytesForUuid, - }, - ).toString(); + config: V4Options(randomBytesForUuid, null), + ); final StreamController response = StreamController(); StreamSubscription? sub; final bool addTimeout = @@ -457,6 +502,7 @@ class SocketClient { ) : waitForConnectedStateWithoutTimeout; + sub?.cancel(); sub = waitForConnectedState.listen((_) { final Stream dataErrorComplete = _messages.where( (GraphQLSocketMessage message) { @@ -530,15 +576,17 @@ class SocketClient { .listen((message) => response.addError(message)); if (!_subscriptionInitializers[id]!.hasBeenTriggered) { - GraphQLSocketMessage operation = StartOperation( - id, - serialize(payload), - ); + GraphQLSocketMessage operation; if (protocol == GraphQLProtocol.graphqlTransportWs) { operation = SubscribeOperation( id, serialize(payload), ); + } else { + operation = StartOperation( + id, + serialize(payload), + ); } _write(operation); _subscriptionInitializers[id]!.hasBeenTriggered = true; @@ -556,6 +604,10 @@ class SocketClient { _connectionStateController.value == SocketConnectionState.connected && socketChannel != null) { _write(StopOperation(id)); + } else if (protocol == GraphQLProtocol.graphqlTransportWs && + _connectionStateController.value == SocketConnectionState.connected && + socketChannel != null) { + _write(SubscriptionComplete(id)); } }; @@ -586,8 +638,14 @@ class GraphQLWebSocketChannel extends StreamChannelMixin Stream? _messages; /// Stream of messages from the endpoint parsed as GraphQLSocketMessages - Stream get messages => _messages ??= - stream.map(GraphQLSocketMessage.parse); + Stream get messages { + if (_messages == null) + _messages = stream.map((event) { + return GraphQLSocketMessage.parse(event); + }).asBroadcastStream(); + + return _messages!; + } String? get protocol => _webSocket.protocol; diff --git a/packages/graphql/lib/src/links/websocket_link/websocket_link.dart b/packages/graphql/lib/src/links/websocket_link/websocket_link.dart index 018d1d248..0525a930b 100644 --- a/packages/graphql/lib/src/links/websocket_link/websocket_link.dart +++ b/packages/graphql/lib/src/links/websocket_link/websocket_link.dart @@ -45,6 +45,8 @@ class WebSocketLink extends Link { ); } + SocketClient? get getSocketClient => _socketClient; + /// Disposes the underlying socket client explicitly. Only use this, if you want to disconnect from /// the current server in favour of another one. If that's the case, create a new [WebSocketLink] instance. Future dispose() async { diff --git a/packages/graphql/lib/src/links/websocket_link/websocket_messages.dart b/packages/graphql/lib/src/links/websocket_link/websocket_messages.dart index c45d2dd6d..dc3bad271 100644 --- a/packages/graphql/lib/src/links/websocket_link/websocket_messages.dart +++ b/packages/graphql/lib/src/links/websocket_link/websocket_messages.dart @@ -87,9 +87,19 @@ abstract class GraphQLSocketMessage extends JsonSerializable { return PongMessage(payload as Map); case MessageTypes.data: - return SubscriptionData(id, payload['data'], payload['errors']); + return SubscriptionData( + id, + payload['data'], + payload['errors'], + payload['extensions'], + ); case MessageTypes.next: - return SubscriptionNext(id, payload['data'], payload['errors']); + return SubscriptionNext( + id, + payload['data'], + payload['errors'], + payload['extensions'], + ); case MessageTypes.error: return SubscriptionError(id, payload); case MessageTypes.complete: @@ -240,46 +250,52 @@ class ConnectionKeepAlive extends GraphQLSocketMessage { /// payload. The user should check the errors result before processing the /// data value. These error are from the query resolvers. class SubscriptionData extends GraphQLSocketMessage { - SubscriptionData(this.id, this.data, this.errors) : super(MessageTypes.data); + SubscriptionData(this.id, this.data, this.errors, this.extensions) + : super(MessageTypes.data); final String id; final dynamic data; final dynamic errors; + final dynamic extensions; @override toJson() => { "type": type, "data": data, "errors": errors, + if (extensions != null) "extensions": extensions, }; @override int get hashCode => toJson().hashCode; @override - bool operator ==(dynamic other) => + bool operator ==(Object other) => other is SubscriptionData && jsonEncode(other) == jsonEncode(this); } class SubscriptionNext extends GraphQLSocketMessage { - SubscriptionNext(this.id, this.data, this.errors) : super(MessageTypes.next); + SubscriptionNext(this.id, this.data, this.errors, this.extensions) + : super(MessageTypes.next); final String id; final dynamic data; final dynamic errors; + final dynamic extensions; @override toJson() => { "type": type, "data": data, "errors": errors, + if (extensions != null) "extensions": extensions, }; @override int get hashCode => toJson().hashCode; @override - bool operator ==(dynamic other) => + bool operator ==(Object other) => other is SubscriptionNext && jsonEncode(other) == jsonEncode(this); } diff --git a/packages/graphql/pubspec.yaml b/packages/graphql/pubspec.yaml index 90bf744af..b48c17d05 100644 --- a/packages/graphql/pubspec.yaml +++ b/packages/graphql/pubspec.yaml @@ -1,6 +1,6 @@ name: graphql description: A stand-alone GraphQL client for Dart, bringing all the features from a modern GraphQL client to one easy to use package. -version: 5.2.0-beta.2 +version: 5.2.0-beta.7 repository: https://github.com/zino-app/graphql-flutter/tree/main/packages/graphql issue_tracker: https://github.com/zino-hofmann/graphql-flutter/issues @@ -16,12 +16,12 @@ dependencies: gql_dedupe_link: ^2.0.3 hive: ^2.1.0 normalize: ^0.8.2 - http: ^0.13.0 + http: ^1.0.0 collection: ^1.15.0 - web_socket_channel: ^2.3.0 + web_socket_channel: '>=2.3.0 <=2.4.0' stream_channel: ^2.1.0 rxdart: ^0.27.1 - uuid: ^3.0.1 + uuid: ^4.0.0 dev_dependencies: async: ^2.5.0 @@ -32,4 +32,4 @@ dev_dependencies: lints: ^1.0.1 environment: - sdk: '>=2.15.0 <=3.0.0' + sdk: '>=2.15.0 <4.0.0' diff --git a/packages/graphql/test/mock_server/ws_echo_server.dart b/packages/graphql/test/mock_server/ws_echo_server.dart index 07c2c1ac8..057bff785 100644 --- a/packages/graphql/test/mock_server/ws_echo_server.dart +++ b/packages/graphql/test/mock_server/ws_echo_server.dart @@ -6,6 +6,7 @@ import 'dart:convert'; import 'dart:io'; const String forceDisconnectCommand = '___force_disconnect___'; +const String forceAuthDisconnectCommand = '___force_auth_disconnect___'; /// Main function to create and run the echo server over the web socket. Future runWebSocketServer( @@ -20,6 +21,8 @@ void onWebSocketData(WebSocket client) { client.listen((data) async { if (data == forceDisconnectCommand) { client.close(WebSocketStatus.normalClosure, 'shutting down'); + } else if (data == forceAuthDisconnectCommand) { + client.close(4001, 'Unauthorized'); } else { final message = json.decode(data.toString()); if (message['type'] == 'connection_init' && diff --git a/packages/graphql/test/query_result_test.dart b/packages/graphql/test/query_result_test.dart new file mode 100644 index 000000000..a6064e074 --- /dev/null +++ b/packages/graphql/test/query_result_test.dart @@ -0,0 +1,76 @@ +import 'package:gql/language.dart'; +import 'package:graphql/client.dart'; +import 'package:test/test.dart'; + +void main() { + group('Query result', () { + test('data parsing should work with null data', () { + int runTimes = 0; + final result = QueryResult( + options: QueryOptions( + document: parseString('query { bar }'), + parserFn: (data) { + runTimes++; + return data['bar'] as String?; + }, + ), + source: QueryResultSource.network, + data: null, + ); + expect(result.parsedData, equals(null)); + expect(runTimes, equals(0)); + }); + test('data parsing should work with data', () { + int runTimes = 0; + final bar = "Bar"; + final result = QueryResult( + options: QueryOptions( + document: parseString('query { bar }'), + parserFn: (data) { + runTimes++; + return data['bar'] as String?; + }, + ), + source: QueryResultSource.network, + data: {"bar": bar}, + ); + expect(result.parsedData, equals(bar)); + expect(result.parsedData, equals(bar)); + expect(runTimes, equals(1)); + }); + test('data parsing should work with data', () { + final bar = "Bar"; + final result = QueryResult( + options: QueryOptions( + document: parseString('query { bar }'), + parserFn: (data) { + return data['bar'] as String?; + }, + ), + source: QueryResultSource.network, + data: {"bar": bar}, + ); + expect(result.data, equals({"bar": bar})); + }); + test('updating data should clear parsed data', () { + int runTimes = 0; + final bar = "Bar"; + final result = QueryResult( + options: QueryOptions( + document: parseString('query { bar }'), + parserFn: (data) { + runTimes++; + return data['bar'] as String?; + }, + ), + source: QueryResultSource.network, + data: {"bar": bar}, + ); + expect(result.parsedData, equals(bar)); + expect(runTimes, equals(1)); + result.data = {"bar": bar}; + expect(result.parsedData, equals(bar)); + expect(runTimes, equals(2)); + }); + }); +} diff --git a/packages/graphql/test/websocket_test.dart b/packages/graphql/test/websocket_test.dart index 227b9046d..87e5483c7 100644 --- a/packages/graphql/test/websocket_test.dart +++ b/packages/graphql/test/websocket_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:rxdart/rxdart.dart'; import 'package:test/test.dart'; import 'dart:convert'; import 'dart:typed_data'; @@ -18,6 +19,7 @@ SocketClient getTestClient({ Map? customHeaders, Duration delayBetweenReconnectionAttempts = const Duration(milliseconds: 1), String protocol = GraphQLProtocol.graphqlWs, + Stream? toggleConnection, }) => SocketClient( wsUrl, @@ -29,6 +31,7 @@ SocketClient getTestClient({ initialPayload: { 'protocol': protocol, }, + toggleConnection: toggleConnection, ), randomBytesForUuid: Uint8List.fromList( [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], @@ -226,6 +229,59 @@ Future main() async { ), ); }); + test('subscription data with extensions', () async { + final payload = Request( + operation: Operation(document: parseString('subscription {}')), + ); + final waitForConnection = true; + final subscriptionDataStream = + socketClient.subscribe(payload, waitForConnection); + await socketClient.connectionState + .where((state) => state == SocketConnectionState.connected) + .first; + + // ignore: unawaited_futures + socketClient.socketChannel!.stream + .where((message) => message == expectedMessage) + .first + .then((_) { + socketClient.socketChannel!.sink.add(jsonEncode({ + 'type': 'data', + 'id': '01020304-0506-4708-890a-0b0c0d0e0f10', + 'payload': { + 'data': {'foo': 'bar'}, + 'errors': [ + {'message': 'error and data can coexist'} + ], + 'extensions': {'extensionKey': 'extensionValue'}, + } + })); + }); + + await expectLater( + subscriptionDataStream, + emits( + // todo should ids be included in response context? probably '01020304-0506-4708-890a-0b0c0d0e0f10' + Response( + data: {'foo': 'bar'}, + errors: [ + GraphQLError(message: 'error and data can coexist'), + ], + context: Context().withEntry(ResponseExtensions({ + 'extensionKey': 'extensionValue', + })), + response: { + "type": "data", + "data": {"foo": "bar"}, + "errors": [ + {"message": "error and data can coexist"} + ], + "extensions": {'extensionKey': 'extensionValue'}, + }, + ), + ), + ); + }); test('resubscribe', () async { final payload = Request( operation: Operation(document: gql('subscription {}')), @@ -376,12 +432,17 @@ Future main() async { r'"type":"subscribe","id":"01020304-0506-4708-890a-0b0c0d0e0f10",' r'"payload":{"operationName":null,"variables":{},"query":"subscription {\n \n}"}' r'}'; + late PublishSubject toggleConnection; + setUp(overridePrint((log) { + toggleConnection = new PublishSubject(); + controller = StreamController(sync: true); socketClient = getTestClient( controller: controller, protocol: GraphQLProtocol.graphqlTransportWs, wsUrl: wsUrl, + toggleConnection: toggleConnection, ); })); tearDown(overridePrint( @@ -635,6 +696,92 @@ Future main() async { ), ); }); + + test('resubscribe after server disconnect and resubscription not paused', + () async { + final payload = Request( + operation: Operation(document: gql('subscription {}')), + ); + final waitForConnection = true; + final subscriptionDataStream = + socketClient.subscribe(payload, waitForConnection); + + await expectLater( + socketClient.connectionState, + emitsInOrder([ + SocketConnectionState.connecting, + SocketConnectionState.handshake, + SocketConnectionState.connected, + ]), + ); + + Timer(const Duration(milliseconds: 10), () async { + toggleConnection.add(ToggleConnectionState.disconnect); + }); + + await expectLater( + socketClient.connectionState, + emitsInOrder([ + SocketConnectionState.connected, + SocketConnectionState.notConnected, + ]), + ); + + Timer(const Duration(milliseconds: 10), () async { + toggleConnection.add(ToggleConnectionState.connect); + }); + + // The connectionState BehaviorController emits the current state + // to any new listener, so we expect it to start in the connected + // state, transition to notConnected, and then reconnect after that. + await expectLater( + socketClient.connectionState, + emitsInOrder([ + SocketConnectionState.notConnected, + SocketConnectionState.connecting, + SocketConnectionState.handshake, + SocketConnectionState.connected, + ]), + ); + + // ignore: unawaited_futures + socketClient.socketChannel!.stream + .where((message) => message == expectedMessage) + .first + .then((_) { + socketClient.socketChannel!.sink.add(jsonEncode({ + 'type': 'next', + 'id': '01020304-0506-4708-890a-0b0c0d0e0f10', + 'payload': { + 'data': {'foo': 'bar'}, + 'errors': [ + {'message': 'error and data can coexist'} + ] + } + })); + }); + + await expectLater( + subscriptionDataStream, + emits( + // todo should ids be included in response context? probably '01020304-0506-4708-890a-0b0c0d0e0f10' + Response( + data: {'foo': 'bar'}, + errors: [ + GraphQLError(message: 'error and data can coexist'), + ], + context: Context().withEntry(ResponseExtensions(null)), + response: { + "type": "next", + "data": {"foo": "bar"}, + "errors": [ + {"message": "error and data can coexist"} + ] + }, + ), + ), + ); + }); }, tags: "integration"); group('SocketClient without autoReconnect', () { @@ -765,4 +912,80 @@ Future main() async { }); */ }); + + group('SocketClient with dynamic payload', () { + late SocketClient socketClient; + + var _token = 'mytoken'; + var getToken = () => _token; + + setUp(overridePrint((log) { + socketClient = SocketClient( + wsUrl, + protocol: GraphQLProtocol.graphqlWs, + config: SocketClientConfig( + delayBetweenReconnectionAttempts: const Duration(milliseconds: 1), + initialPayload: () => + {'token': getToken(), 'protocol': GraphQLProtocol.graphqlWs}, + onConnectionLost: (code, reason) async { + if (code == 4001) { + _token = 'mytoken2'; + } + + return Duration.zero; + }, + ), + ); + })); + + tearDown(overridePrint( + (log) => socketClient.dispose(), + )); + + test('resubscribe with new auth token', () async { + await expectLater( + socketClient.connectionState, + emitsInOrder([ + SocketConnectionState.connecting, + SocketConnectionState.connected, + ]), + ); + + await expectLater( + socketClient.socketChannel!.stream.map((s) { + return jsonDecode(s as String)['payload']['token']; + }), + emits('mytoken')); + + // We need to begin waiting on the connectionState + // before we issue the command to disconnect; otherwise + // it can reconnect so fast that it will be reconnected + // by the time that the expectLater check is initiated. + Timer(const Duration(milliseconds: 20), () async { + socketClient.socketChannel!.sink.add(forceAuthDisconnectCommand); + }); + // The connectionState BehaviorController emits the current state + // to any new listener, so we expect it to start in the connected + // state, transition to notConnected, and then reconnect after that. + await expectLater( + socketClient.connectionState, + emitsInOrder([ + SocketConnectionState.connected, + SocketConnectionState.notConnected, + SocketConnectionState.connecting, + SocketConnectionState.connected, + ]), + ); + + await socketClient.connectionState + .where((state) => state == SocketConnectionState.connected) + .first; + + await expectLater( + socketClient.socketChannel!.stream.map((s) { + return jsonDecode(s as String)['payload']['token']; + }), + emits('mytoken2')); + }); + }); } diff --git a/packages/graphql_flutter/CHANGELOG.md b/packages/graphql_flutter/CHANGELOG.md index 1155b95b6..d0bcfa830 100644 --- a/packages/graphql_flutter/CHANGELOG.md +++ b/packages/graphql_flutter/CHANGELOG.md @@ -1,3 +1,28 @@ +# v5.2.0-beta.6 + +## Added +- bump connectivity_plus to 5.0.0 ([commit](https://github.com/zino-hofmann/graphql-flutter/commit/dd53e61f8c1410ced70e4cdc665bb8e89e5fbeb4)). @erknvl 10-10-2023 + + +# v5.2.0-beta.5 + +## Fixed +- bumps http to v1 ([commit](https://github.com/zino-hofmann/graphql-flutter/commit/396c3b3f6986b6d3174e548982a93188b49ee5bc)). @moisessantos 06-07-2023 + + +# v5.2.0-beta.4 + +## Added +- Cache parsed data ([commit](https://github.com/zino-hofmann/graphql-flutter/commit/aa81251f71f7a5f566eae4a9575eb6547050c2d9)). @budde377 03-06-2023 +- bump connectivity_plus dependency to ^4.0.0 ([commit](https://github.com/zino-hofmann/graphql-flutter/commit/776d07063a4f8160daa4b6baa0af4be8517a62a0)). @ndelanou 24-05-2023 + + +# v5.2.0-beta.3 + +## Added +- bump sdk version upper bound to <4.0.0 ([commit](https://github.com/zino-hofmann/graphql-flutter/commit/8bb9ba355e53dccf5e291b1f05171459bf8867ed)). @ndelanou 17-05-2023 + + # v5.2.0-beta.2 ## Fixed diff --git a/packages/graphql_flutter/changelog.json b/packages/graphql_flutter/changelog.json index 35715cb8d..b6ad81ee1 100644 --- a/packages/graphql_flutter/changelog.json +++ b/packages/graphql_flutter/changelog.json @@ -1,6 +1,6 @@ { "package_name": "graphql_flutter", - "version": "v5.2.0-beta.2", + "version": "v5.2.0-beta.6", "api": { "name": "github", "repository": "zino-hofmann/graphql-flutter", diff --git a/packages/graphql_flutter/example/pubspec.yaml b/packages/graphql_flutter/example/pubspec.yaml index 25f5c0381..63efffdec 100644 --- a/packages/graphql_flutter/example/pubspec.yaml +++ b/packages/graphql_flutter/example/pubspec.yaml @@ -27,5 +27,5 @@ dependency_overrides: path: ../../graphql environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.12.0 <4.0.0' flutter: ">=2.11.0" diff --git a/packages/graphql_flutter/pubspec.yaml b/packages/graphql_flutter/pubspec.yaml index 1db72f18b..0c02eaf23 100644 --- a/packages/graphql_flutter/pubspec.yaml +++ b/packages/graphql_flutter/pubspec.yaml @@ -1,6 +1,6 @@ name: graphql_flutter description: A GraphQL client for Flutter, bringing all the features from a modern GraphQL client to one easy to use package. -version: 5.2.0-beta.2 +version: 5.2.0-beta.6 repository: https://github.com/zino-app/graphql-flutter/tree/main/packages/graphql_flutter issue_tracker: https://github.com/zino-hofmann/graphql-flutter/issues @@ -8,7 +8,7 @@ issue_tracker: https://github.com/zino-hofmann/graphql-flutter/issues # publish_to: 'none' dependencies: - graphql: ^5.2.0-beta.2 + graphql: ^5.2.0-beta.4 gql_exec: ^1.0.0 flutter: sdk: flutter @@ -18,14 +18,14 @@ dependencies: connectivity_plus: ^6.0.2 hive: ^2.0.0 plugin_platform_interface: ^2.0.0 - flutter_hooks: ^0.18.2 + flutter_hooks: '>=0.18.2 <0.21.0' dev_dependencies: pedantic: ^1.11.0 mockito: ^5.0.0 flutter_test: sdk: flutter - http: ^0.13.0 + http: ^1.0.0 test: ^1.17.12 environment: