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 new file mode 100644 index 000000000..f3693340b --- /dev/null +++ b/packages/graphql/lib/src/links/persisted_queries_link.dart @@ -0,0 +1,220 @@ +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'; + +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; + +typedef QueryHashGenerator = String Function(DocumentNode query); + +typedef ShouldDisablePersistedQueries = bool Function( + Request request, + Response response, [ + HttpLinkServerException exception, +]); + +extension on Operation { + bool get isQuery => isOfType(OperationType.query, document, operationName); +} + +class PersistedQueriesLink extends Link { + bool disabledDueToErrors = false; + + /// Adds a [HttpLinkMethod.get()] to context entry for hashed queries + final bool useGETForHashedQueries; + + /// callback for hashing queries. + /// + /// Defaults to [defaultSha256Hash] + 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, + ]) { + if (forward == null) { + throw Exception( + 'PersistedQueryLink cannot be the last link in the chain.', + ); + } + + final operation = request.operation; + + var hashError; + 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) { + assert( + request.operation.document == doc, + 'Request document altered after PersistedQueriesLink: ' + '${printNode(request.operation.document)} != ${printNode(doc)}', + ); + return { + 'persistedQuery': { + 'sha256Hash': hash, + 'version': VERSION, + }, + }; + }, + ), + ); + } catch (e) { + hashError = e; + } + } + + StreamController controller; + + Future onListen() async { + if (hashError != null) { + return controller.addError(hashError); + } + + StreamSubscription subscription; + bool retried = false; + Request originalRequest = request; + + Function retry; + retry = ({ + Response response, + HttpLinkServerException networkError, + Function callback, + }) { + 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); + + // if its not found, we can try it again, otherwise just report the error + if (!includesNotSupportedError(response) || disabledDueToErrors) { + // need to recall the link chain + if (subscription != null) { + subscription.cancel(); + } + + // actually send the query this time + final retryRequest = originalRequest.withContextEntry( + RequestSerializationInclusions( + query: true, + extensions: !disabledDueToErrors, + ), + ); + + subscription = _attachListener( + controller, + forward(retryRequest), + retry, + ); + + return; + } + } + + callback(); + }; + + // don't send the query the first time + request = request.withContextEntry( + RequestSerializationInclusions( + query: disabledDueToErrors, + extensions: !disabledDueToErrors, + ), + ); + + // If requested, set method to GET if there are no mutations. Remember the + if (useGETForHashedQueries && !disabledDueToErrors && operation.isQuery) { + request = request.withContextEntry(HttpLinkMethod.get()); + } + + 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, [ + HttpLinkServerException 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 HttpLinkServerException) { + retry(networkError: err, callback: () => controller.addError(err)); + } else { + controller.addError(err); + } + }, + onDone: () { + controller.close(); + }, + cancelOnError: true, + ); + } +} 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; - } -} 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, + ); + }); + }); +}