From a4d8dcc274607a6db1f5427885d7b9c4ff37060c Mon Sep 17 00:00:00 2001 From: micimize Date: Sat, 25 Jul 2020 12:01:25 -0700 Subject: [PATCH 1/3] feat(graphql): persisted query work --- .../lib/src/links/persisted_queries_link.dart | 226 ++++++++++++++++++ .../graphql/lib/src/utilities/traverse.dart | 62 ----- 2 files changed, 226 insertions(+), 62 deletions(-) create mode 100644 packages/graphql/lib/src/links/persisted_queries_link.dart delete mode 100644 packages/graphql/lib/src/utilities/traverse.dart diff --git a/packages/graphql/lib/src/links/persisted_queries_link.dart b/packages/graphql/lib/src/links/persisted_queries_link.dart new file mode 100644 index 000000000..ac9c4de19 --- /dev/null +++ b/packages/graphql/lib/src/links/persisted_queries_link.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:graphql/src/utilities/get_from_ast.dart'; +import 'package:meta/meta.dart'; + +import 'package:crypto/crypto.dart'; +import 'package:gql/ast.dart'; +import 'package:gql/language.dart'; +import 'package:graphql/client.dart'; + +export 'package:gql_error_link/gql_error_link.dart'; +export 'package:gql_link/gql_link.dart'; +import 'package:graphql/src/exceptions/exceptions_next.dart' as ex; + +const VERSION = 1; + +typedef QueryHashGenerator = String Function(DocumentNode query); + +typedef ShouldDisablePersistedQueries = bool Function( + Request request, + Response response, [ + LinkException exception, +]); + +extension on Operation { + bool get isQuery => isOfType(OperationType.query, document, operationName); +} + +class PersistedQueriesLink extends Link { + bool disabledDueToErrors = true; + + /// Adds a [HttpLinkMethod.get()] to context entry for hashed queries + final bool useGETForHashedQueries; + + /// callback for hashing queries. + /// + /// Defaults to [simpleSha256Hash] + final QueryHashGenerator getQueryHash; + + /// Called when [response] has errors to determine if the [PersistedQueriesLink] should be disabled + /// + /// Defaults to [defaultDisableOnError] + final ShouldDisablePersistedQueries disableOnError; + + PersistedQueriesLink({ + this.useGETForHashedQueries = true, + this.getQueryHash = defaultSha256Hash, + this.disableOnError = defaultDisableOnError, + }) : super(); + + @override + Stream request( + Request request, [ + NextLink forward, + ]) async* { + if (forward == null) { + throw Exception( + 'PersistedQueryLink cannot be the last link in the chain.', + ); + } + + final operation = request.operation; + + var hashError; + if (disabledDueToErrors) { + try { + request = request.withContextEntry( + RequestExtensionsThunk( + (request) => { + 'persistedQuery': { + 'sha256Hash': getQueryHash(request.operation.document), + 'version': VERSION, + }, + }, + ), + ); + } catch (e) { + hashError = e; + } + } + + StreamController controller; + Future onListen() async { + if (hashError != null) { + return controller.addError(hashError); + } + + StreamSubscription subscription; + bool retried = false; + Map originalFetchOptions; + bool setFetchOptions = false; + + Function retry; + retry = ({ + Response response, + NetworkException networkError, + Function callback, + }) { + coalesceErrors( + graphqlErrors: response.errors, + linkException: networkError, + ); + if (!retried && (response?.errors != null || networkError != null)) { + retried = true; + + // if the server doesn't support persisted queries, don't try anymore + disabledDueToErrors = disableOnError(request, response, networkError); + + // if its not found, we can try it again, otherwise just report the error + if (!disabledDueToErrors) { + // need to recall the link chain + if (subscription != null) { + subscription.cancel(); + } + + // actually send the query this time + operation.setContext({ + 'http': { + 'includeQuery': true, + 'includeExtensions': disabledDueToErrors, + }, + }); + if (setFetchOptions) { + operation.setContext({'fetchOptions': originalFetchOptions}); + } + subscription = + _attachListener(controller, forward(operation), retry); + + return; + } + } + + callback(); + }; + + // don't send the query the first time + operation.setContext({ + 'http': { + 'includeQuery': !disabledDueToErrors, + 'includeExtensions': disabledDueToErrors, + }, + }); + + // If requested, set method to GET if there are no mutations. Remember the + // original fetchOptions so we can restore them if we fall back to a + // non-hashed request. + if (useGETForHashedQueries && disabledDueToErrors && operation.isQuery) { + final context = operation.getContext(); + originalFetchOptions = context['fetchOptions'] ?? {}; + operation.setContext({ + 'fetchOptions': { + ...originalFetchOptions, + 'method': 'GET', + }, + }); + setFetchOptions = true; + } + + subscription = _attachListener(controller, forward(request), retry); + } + + controller = StreamController(onListen: onListen); + + return controller.stream; + } + + /// Default [getQueryHash] that `sha256` encodes the query document string + static String defaultSha256Hash(DocumentNode query) => + sha256.convert(utf8.encode(printNode(query))).toString(); + + /// Default [disableOnError]. + /// + /// Disables the link if [includesNotSupportedError(response)] or if `statusCode` is in `{ 400, 500 }` + static bool defaultDisableOnError(Request request, Response response, + [LinkException exception]) { + // if the server doesn't support persisted queries, don't try anymore + if (includesNotSupportedError(response)) { + return true; + } + + // if the server responds with bad request + // apollo-server responds with 400 for GET and 500 for POST when no query is found + + final HttpLinkResponseContext responseContext = response.context.entry(); + + return {400, 500}.contains(responseContext.statusCode); + } + + static bool includesNotSupportedError(Response response) { + final errors = response?.errors ?? []; + return errors.any( + (err) => err.message == 'PersistedQueryNotSupported', + ); + } + + StreamSubscription _attachListener( + StreamController controller, + Stream stream, + Function retry, + ) { + return stream.listen( + (data) { + retry(response: data, callback: () => controller.add(data)); + }, + onError: (err) { + if (err is NetworkException) { + retry( + networkError: ex.translateFailure(err), + callback: () => controller.addError(err)); + } else { + controller.addError(err); + } + }, + onDone: () { + controller.close(); + }, + cancelOnError: true, + ); + } +} + +class _RetryHandler { + bool retried; + + void Function() onGiveUp; +} diff --git a/packages/graphql/lib/src/utilities/traverse.dart b/packages/graphql/lib/src/utilities/traverse.dart deleted file mode 100644 index 63a13c2ef..000000000 --- a/packages/graphql/lib/src/utilities/traverse.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'dart:collection'; - -typedef Transform = Object Function(Object node); -typedef SideEffect = void Function( - Object transformResult, - Object node, - Traversal traversal, -); - -class Traversal { - Traversal( - this.transform, { - this.transformSideEffect, - this.seenObjects, - }) { - seenObjects ??= HashSet(); - } - - Transform transform; - - /// An optional side effect to call when a node is transformed. - SideEffect transformSideEffect; - HashSet seenObjects; - - bool alreadySeen(Object node) { - final bool wasAdded = seenObjects.add(node); - return !wasAdded; - } - - /// Traverse only the values of the given map - Map traverseValues(Map node) { - return node.map( - (String key, Object value) => MapEntry( - key, - traverse(value), - ), - ); - } - - // Attempts to apply the transform to every leaf of the data structure recursively. - // Stops recursing when a node is transformed (returns non-null) - Object traverse(Object node) { - final Object transformed = transform(node); - if (alreadySeen(node)) { - return transformed ?? node; - } - if (transformed != null) { - if (transformSideEffect != null) { - transformSideEffect(transformed, node, this); - } - return transformed; - } - - if (node is List) { - return node.map((Object node) => traverse(node)).toList(); - } - if (node is Map) { - return traverseValues(node); - } - return node; - } -} From 9d40fd3948f28a5a7c52c1ff3708b7a9f14855c5 Mon Sep 17 00:00:00 2001 From: micimize Date: Sat, 25 Jul 2020 12:12:48 -0700 Subject: [PATCH 2/3] more apq progress --- .../lib/src/links/persisted_queries_link.dart | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/packages/graphql/lib/src/links/persisted_queries_link.dart b/packages/graphql/lib/src/links/persisted_queries_link.dart index ac9c4de19..6349eddeb 100644 --- a/packages/graphql/lib/src/links/persisted_queries_link.dart +++ b/packages/graphql/lib/src/links/persisted_queries_link.dart @@ -19,7 +19,7 @@ typedef QueryHashGenerator = String Function(DocumentNode query); typedef ShouldDisablePersistedQueries = bool Function( Request request, Response response, [ - LinkException exception, + HttpLinkServerException exception, ]); extension on Operation { @@ -52,7 +52,7 @@ class PersistedQueriesLink extends Link { Stream request( Request request, [ NextLink forward, - ]) async* { + ]) { if (forward == null) { throw Exception( 'PersistedQueryLink cannot be the last link in the chain.', @@ -80,6 +80,7 @@ class PersistedQueriesLink extends Link { } StreamController controller; + Future onListen() async { if (hashError != null) { return controller.addError(hashError); @@ -87,22 +88,18 @@ class PersistedQueriesLink extends Link { StreamSubscription subscription; bool retried = false; - Map originalFetchOptions; bool setFetchOptions = false; Function retry; retry = ({ Response response, - NetworkException networkError, + HttpLinkServerException networkError, Function callback, }) { - coalesceErrors( - graphqlErrors: response.errors, - linkException: networkError, - ); if (!retried && (response?.errors != null || networkError != null)) { retried = true; + // TODO triple check that the original wholesale disables the link // if the server doesn't support persisted queries, don't try anymore disabledDueToErrors = disableOnError(request, response, networkError); @@ -117,12 +114,9 @@ class PersistedQueriesLink extends Link { operation.setContext({ 'http': { 'includeQuery': true, - 'includeExtensions': disabledDueToErrors, + 'includeExtensions': !disabledDueToErrors, }, }); - if (setFetchOptions) { - operation.setContext({'fetchOptions': originalFetchOptions}); - } subscription = _attachListener(controller, forward(operation), retry); @@ -171,8 +165,11 @@ class PersistedQueriesLink extends Link { /// Default [disableOnError]. /// /// Disables the link if [includesNotSupportedError(response)] or if `statusCode` is in `{ 400, 500 }` - static bool defaultDisableOnError(Request request, Response response, - [LinkException exception]) { + static bool defaultDisableOnError( + Request request, + Response response, [ + HttpLinkServerException exception, + ]) { // if the server doesn't support persisted queries, don't try anymore if (includesNotSupportedError(response)) { return true; @@ -203,10 +200,8 @@ class PersistedQueriesLink extends Link { retry(response: data, callback: () => controller.add(data)); }, onError: (err) { - if (err is NetworkException) { - retry( - networkError: ex.translateFailure(err), - callback: () => controller.addError(err)); + if (err is HttpLinkServerException) { + retry(networkError: err, callback: () => controller.addError(err)); } else { controller.addError(err); } From 632fc5c08a59afc53618f1cf1dd338978b3b1819 Mon Sep 17 00:00:00 2001 From: micimize Date: Sun, 26 Jul 2020 11:18:14 -0500 Subject: [PATCH 3/3] feat(graphql): persisted queries migration/prototype --- packages/graphql/lib/src/links/links.dart | 2 + .../lib/src/links/persisted_queries_link.dart | 87 ++-- packages/graphql/pubspec.yaml | 12 + packages/graphql/test/link_http_apq_test.dart | 406 ++++++++++++++++++ 4 files changed, 463 insertions(+), 44 deletions(-) create mode 100644 packages/graphql/test/link_http_apq_test.dart diff --git a/packages/graphql/lib/src/links/links.dart b/packages/graphql/lib/src/links/links.dart index ce0e0a0b2..7bf5a2a0d 100644 --- a/packages/graphql/lib/src/links/links.dart +++ b/packages/graphql/lib/src/links/links.dart @@ -2,3 +2,5 @@ export 'package:graphql/src/links/gql_links.dart'; export 'package:graphql/src/links/auth_link.dart'; + +export 'package:graphql/src/links/persisted_queries_link.dart'; diff --git a/packages/graphql/lib/src/links/persisted_queries_link.dart b/packages/graphql/lib/src/links/persisted_queries_link.dart index 6349eddeb..f3693340b 100644 --- a/packages/graphql/lib/src/links/persisted_queries_link.dart +++ b/packages/graphql/lib/src/links/persisted_queries_link.dart @@ -8,8 +8,9 @@ import 'package:gql/ast.dart'; import 'package:gql/language.dart'; import 'package:graphql/client.dart'; -export 'package:gql_error_link/gql_error_link.dart'; -export 'package:gql_link/gql_link.dart'; +import 'package:gql_link/gql_link.dart'; +import 'package:gql_http_link/gql_http_link.dart'; + import 'package:graphql/src/exceptions/exceptions_next.dart' as ex; const VERSION = 1; @@ -27,14 +28,14 @@ extension on Operation { } class PersistedQueriesLink extends Link { - bool disabledDueToErrors = true; + bool disabledDueToErrors = false; /// Adds a [HttpLinkMethod.get()] to context entry for hashed queries final bool useGETForHashedQueries; /// callback for hashing queries. /// - /// Defaults to [simpleSha256Hash] + /// Defaults to [defaultSha256Hash] final QueryHashGenerator getQueryHash; /// Called when [response] has errors to determine if the [PersistedQueriesLink] should be disabled @@ -62,15 +63,25 @@ class PersistedQueriesLink extends Link { final operation = request.operation; var hashError; - if (disabledDueToErrors) { + if (!disabledDueToErrors) { try { + final doc = request.operation.document; + final hash = getQueryHash(doc); + // TODO awkward to inject the hash with a thunk like this request = request.withContextEntry( RequestExtensionsThunk( - (request) => { - 'persistedQuery': { - 'sha256Hash': getQueryHash(request.operation.document), - 'version': VERSION, - }, + (request) { + assert( + request.operation.document == doc, + 'Request document altered after PersistedQueriesLink: ' + '${printNode(request.operation.document)} != ${printNode(doc)}', + ); + return { + 'persistedQuery': { + 'sha256Hash': hash, + 'version': VERSION, + }, + }; }, ), ); @@ -88,7 +99,7 @@ class PersistedQueriesLink extends Link { StreamSubscription subscription; bool retried = false; - bool setFetchOptions = false; + Request originalRequest = request; Function retry; retry = ({ @@ -104,21 +115,25 @@ class PersistedQueriesLink extends Link { disabledDueToErrors = disableOnError(request, response, networkError); // if its not found, we can try it again, otherwise just report the error - if (!disabledDueToErrors) { + if (!includesNotSupportedError(response) || disabledDueToErrors) { // need to recall the link chain if (subscription != null) { subscription.cancel(); } // actually send the query this time - operation.setContext({ - 'http': { - 'includeQuery': true, - 'includeExtensions': !disabledDueToErrors, - }, - }); - subscription = - _attachListener(controller, forward(operation), retry); + final retryRequest = originalRequest.withContextEntry( + RequestSerializationInclusions( + query: true, + extensions: !disabledDueToErrors, + ), + ); + + subscription = _attachListener( + controller, + forward(retryRequest), + retry, + ); return; } @@ -128,26 +143,16 @@ class PersistedQueriesLink extends Link { }; // don't send the query the first time - operation.setContext({ - 'http': { - 'includeQuery': !disabledDueToErrors, - 'includeExtensions': disabledDueToErrors, - }, - }); + request = request.withContextEntry( + RequestSerializationInclusions( + query: disabledDueToErrors, + extensions: !disabledDueToErrors, + ), + ); // If requested, set method to GET if there are no mutations. Remember the - // original fetchOptions so we can restore them if we fall back to a - // non-hashed request. - if (useGETForHashedQueries && disabledDueToErrors && operation.isQuery) { - final context = operation.getContext(); - originalFetchOptions = context['fetchOptions'] ?? {}; - operation.setContext({ - 'fetchOptions': { - ...originalFetchOptions, - 'method': 'GET', - }, - }); - setFetchOptions = true; + if (useGETForHashedQueries && !disabledDueToErrors && operation.isQuery) { + request = request.withContextEntry(HttpLinkMethod.get()); } subscription = _attachListener(controller, forward(request), retry); @@ -213,9 +218,3 @@ class PersistedQueriesLink extends Link { ); } } - -class _RetryHandler { - bool retried; - - void Function() onGiveUp; -} diff --git a/packages/graphql/pubspec.yaml b/packages/graphql/pubspec.yaml index 6f10abda8..0cbc3399c 100644 --- a/packages/graphql/pubspec.yaml +++ b/packages/graphql/pubspec.yaml @@ -30,3 +30,15 @@ dev_dependencies: environment: sdk: ">=2.6.0 <3.0.0" + +dependency_overrides: + gql_link: + git: + url: git@github.com:micimize/gql.git + ref: apq_changes + path: links/gql_link + gql_http_link: + git: + url: git@github.com:micimize/gql.git + ref: apq_changes + path: links/gql_http_link diff --git a/packages/graphql/test/link_http_apq_test.dart b/packages/graphql/test/link_http_apq_test.dart new file mode 100644 index 000000000..67efe9bdc --- /dev/null +++ b/packages/graphql/test/link_http_apq_test.dart @@ -0,0 +1,406 @@ +import "dart:async"; +import "dart:convert"; +import 'dart:io'; + +import 'package:gql/language.dart'; +import 'package:graphql/client.dart'; +import 'package:graphql/src/links/persisted_queries_link.dart'; +import "package:http/http.dart" as http; +import "package:mockito/mockito.dart"; +import "package:test/test.dart"; + +class MockClient extends Mock implements http.Client {} + +Response getEmptyResponse() => Response().withContextEntry( + HttpLinkResponseContext( + headers: {}, + statusCode: 200, + ), + ); + +void main() { + group('Automatic Persisted Queries link integrated with HttpLink', () { + MockClient client; + Operation query; + Link link; + Request request; + + setUp(() { + client = MockClient(); + query = Operation( + document: parseString('query Operation {}'), + operationName: 'Operation', + ); + request = Request(operation: query); + link = PersistedQueriesLink( + useGETForHashedQueries: true, + ).concat( + HttpLink( + '/graphql-apq-test', + httpClient: client, + ), + ); + }); + + test('request persisted query', () async { + when( + client.send(any), + ).thenAnswer( + (_) => Future.value( + http.StreamedResponse( + Stream.fromIterable( + [utf8.encode('{"data":{}}')], + ), + 200, + ), + ), + ); + + await link.request(request).first; + + final http.Request captured = verify( + client.send(captureAny), + ).captured.single; + + final extensions = + json.decode(captured.url.queryParameters['extensions']); + + expect( + captured.url, + Uri.parse( + '/graphql-apq-test?operationName=Operation&variables=%7B%7D&extensions=%7B%22persistedQuery%22%3A%7B%22sha256Hash%22%3A%228c4ae5b728c7cd94514caf043b362244c226a39dc29517ddbfb9a827abd2faa5%22%2C%22version%22%3A1%7D%7D'), + ); + expect( + extensions['persistedQuery']['sha256Hash'], + '8c4ae5b728c7cd94514caf043b362244c226a39dc29517ddbfb9a827abd2faa5', + ); + expect( + captured.method, + 'GET', + ); + expect( + captured.headers, + equals({ + 'Accept': '*/*', + 'Content-type': 'application/json', + }), + ); + expect( + captured.body, + '', + ); + }); + + test('handle "PERSISTED_QUERY_NOT_FOUND"', () async { + when( + client.send(any), + )..thenAnswer( + (inv) { + http.Request request = inv.positionalArguments[0]; + return Future.value( + http.StreamedResponse( + Stream.fromIterable( + [ + utf8.encode(request.method == 'GET' + ? '{"errors":[{"extensions": { "code": "PERSISTED_QUERY_NOT_FOUND" }, "message": "PersistedQueryNotFound" }]}' + : '{"data":{}}') + ], + ), + 200, + ), + ); + }, + ); + + final result = await link.request(request).first; + + final captured = verify( + client.send(captureAny), + ).captured.cast(); + + final extensions = json.decode( + captured.first.url.queryParameters['extensions'], + ); + final postBody = json.decode(captured[1].body); + + expect( + captured.length, + 2, + ); + expect( + captured.first.method, + 'GET', + ); + expect( + extensions['persistedQuery']['sha256Hash'], + '8c4ae5b728c7cd94514caf043b362244c226a39dc29517ddbfb9a827abd2faa5', + ); + expect( + captured.first.url, + Uri.parse( + '/graphql-apq-test?operationName=Operation&variables=%7B%7D&extensions=%7B%22persistedQuery%22%3A%7B%22sha256Hash%22%3A%228c4ae5b728c7cd94514caf043b362244c226a39dc29517ddbfb9a827abd2faa5%22%2C%22version%22%3A1%7D%7D'), + ); + expect( + captured[1].method, + 'POST', + ); + expect( + postBody['extensions']['persistedQuery']['sha256Hash'], + '8c4ae5b728c7cd94514caf043b362244c226a39dc29517ddbfb9a827abd2faa5', + ); + expect( + postBody.containsKey('query'), + isTrue, + ); + + final HttpLinkResponseContext resp = result.context.entry(); + expect( + resp.statusCode, + 200, + ); + }); + + test('handle server that does not support persisted queries', () async { + when( + client.send(any), + )..thenAnswer( + (inv) { + http.Request request = inv.positionalArguments[0]; + return Future.value( + http.StreamedResponse( + Stream.fromIterable( + [ + utf8.encode(request.method == 'GET' + ? '{"errors":[{"extensions": { "code": "PERSISTED_QUERY_NOT_SUPPORTED" }, "message": "PersistedQueryNotSupported" }]}' + : '{"data":{}}') + ], + ), + 200, + ), + ); + }, + ); + + final result = await link.request(request).first; + + final captured = List.from(verify( + client.send(captureAny), + ).captured); + + expect( + captured.length, + 2, + ); + + final extensions = + json.decode(captured.first.url.queryParameters['extensions']); + final postBody = json.decode(captured[1].body); + + expect( + captured.first.method, + 'GET', + ); + expect( + extensions['persistedQuery']['sha256Hash'], + '8c4ae5b728c7cd94514caf043b362244c226a39dc29517ddbfb9a827abd2faa5', + ); + expect( + captured.first.url, + Uri.parse( + '/graphql-apq-test?operationName=Operation&variables=%7B%7D&extensions=%7B%22persistedQuery%22%3A%7B%22sha256Hash%22%3A%228c4ae5b728c7cd94514caf043b362244c226a39dc29517ddbfb9a827abd2faa5%22%2C%22version%22%3A1%7D%7D'), + ); + expect( + captured[1].method, + 'POST', + ); + expect( + postBody.containsKey('extensions'), + isFalse, + ); + expect( + postBody.containsKey('query'), + isTrue, + ); + final HttpLinkResponseContext resp = result.context.entry(); + expect( + resp.statusCode, + 200, + ); + }); + + test('unsubscribes correctly', () async { + final link = Link.from([ + PersistedQueriesLink(), + Link.function((request, [forward]) { + return Stream.fromFuture(Future.delayed( + Duration(milliseconds: 100), + getEmptyResponse, + )); + }) + ]); + + StreamSubscription subscription = link.request(request).listen( + (_) => fail('should not complete'), + onError: (_) => fail('should not complete'), + onDone: () => fail('should not complete'), + ); + + await Future.delayed(Duration(milliseconds: 10), () { + subscription.cancel(); + }); + }); + + test('supports loading the hash from other method', () async { + final link = Link.from([ + PersistedQueriesLink( + getQueryHash: (_) => 'custom_hashCode', + ), + HttpLink( + '/graphql-apq-test', + httpClient: client, + ), + ]); + + when( + client.send(any), + ).thenAnswer( + (_) => Future.value( + http.StreamedResponse( + Stream.fromIterable( + [utf8.encode('{"data":{}}')], + ), + 200, + ), + ), + ); + + await link.request(request).first; + + final http.Request captured = verify( + client.send(captureAny), + ).captured.single; + + final extensions = + json.decode(captured.url.queryParameters['extensions']); + + expect( + captured.url, + Uri.parse( + '/graphql-apq-test?operationName=Operation&variables=%7B%7D&extensions=%7B%22persistedQuery%22%3A%7B%22sha256Hash%22%3A%22custom_hashCode%22%2C%22version%22%3A1%7D%7D'), + ); + expect( + extensions['persistedQuery']['sha256Hash'], + 'custom_hashCode', + ); + expect( + captured.method, + 'GET', + ); + }); + + test('errors if unable to hash query', () async { + final link = Link.from([ + PersistedQueriesLink(getQueryHash: (_) { + throw Exception('failed to hash query'); + }), + Link.function((request, [forward]) { + return Stream.value(getEmptyResponse()); + }), + ]); + + expect( + link.request(request).first, + throwsException, + ); + }); + + test('supports a custom disable check function', () async { + final link = Link.from([ + PersistedQueriesLink( + disableOnError: (req, resp, [err]) => false, + ), + HttpLink( + '/graphql-apq-test', + httpClient: client, + ), + ]); + + when( + client.send(any), + )..thenAnswer( + (inv) { + http.Request request = inv.positionalArguments[0]; + return Future.value( + http.StreamedResponse( + Stream.fromIterable( + [ + utf8.encode(request.method == 'GET' + ? '{"errors":[{"extensions": { "code": "PERSISTED_QUERY_NOT_FOUND" }, "message": "PersistedQueryNotFound" }]}' + : '{"data":{}}') + ], + ), + request.method == 'GET' ? 400 : 200, + ), + ); + }, + ); + + final result = await link.request(request).first; + + final captured = List.from(verify( + client.send(captureAny), + ).captured); + + expect( + captured.length, + 2, + ); + expect( + captured.first.method, + 'GET', + ); + expect( + captured.first.url, + Uri.parse( + '/graphql-apq-test?operationName=Operation&variables=%7B%7D&extensions=%7B%22persistedQuery%22%3A%7B%22sha256Hash%22%3A%228c4ae5b728c7cd94514caf043b362244c226a39dc29517ddbfb9a827abd2faa5%22%2C%22version%22%3A1%7D%7D'), + ); + expect( + captured[1].method, + 'POST', + ); + final HttpLinkResponseContext resp = result.context.entry(); + expect( + resp.statusCode, + 200, + ); + }); + + test('errors if no forward link is available', () async { + expect( + () => PersistedQueriesLink().request(request).first, + throwsA(predicate((e) => + e.message == + 'PersistedQueryLink cannot be the last link in the chain.')), + ); + }); + + test('errors if network requests fail', () async { + final link = Link.from([ + PersistedQueriesLink(), + Link.function((request, [forward]) { + return Stream.error( + NetworkException( + uri: Uri.parse("/graphql-apq-test"), + message: 'network error', + ), + ); + }), + ]); + + expect( + () => link.request(request).first, + throwsException, + ); + }); + }); +}