diff --git a/README.md b/README.md index 949b2dbfe..3441b7e11 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Around `graphql_flutter` are builds awesome tools like: ✅   [Optimistic results](./packages/graphql_flutter/README.md#optimism) ✅   [Modularity](./packages/graphql/README.md#links) ✅   [Client-state management](./packages/graphql/README.md#direct-cache-access-api) +✅   [Operation cancellation](./packages/graphql/README.md#cancellation) ⚠️   [Automatic Persisted Queries](./packages/graphql/README.md#persistedquerieslink-experimental-warning-out-of-service-warning) (out of service) ## Contributing diff --git a/examples/starwars/ios/Flutter/ephemeral/flutter_lldb_helper.py b/examples/starwars/ios/Flutter/ephemeral/flutter_lldb_helper.py new file mode 100644 index 000000000..a88caf99d --- /dev/null +++ b/examples/starwars/ios/Flutter/ephemeral/flutter_lldb_helper.py @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +import lldb + +def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): + """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" + base = frame.register["x0"].GetValueAsAddress() + page_len = frame.register["x1"].GetValueAsUnsigned() + + # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the + # first page to see if handled it correctly. This makes diagnosing + # misconfiguration (e.g. missing breakpoint) easier. + data = bytearray(page_len) + data[0:8] = b'IHELPED!' + + error = lldb.SBError() + frame.GetThread().GetProcess().WriteMemory(base, data, error) + if not error.Success(): + print(f'Failed to write into {base}[+{page_len}]', error) + return + +def __lldb_init_module(debugger: lldb.SBDebugger, _): + target = debugger.GetDummyTarget() + # Caveat: must use BreakpointCreateByRegEx here and not + # BreakpointCreateByName. For some reasons callback function does not + # get carried over from dummy target for the later. + bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") + bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) + bp.SetAutoContinue(True) + print("-- LLDB integration loaded --") diff --git a/examples/starwars/ios/Flutter/ephemeral/flutter_lldbinit b/examples/starwars/ios/Flutter/ephemeral/flutter_lldbinit new file mode 100644 index 000000000..e3ba6fbed --- /dev/null +++ b/examples/starwars/ios/Flutter/ephemeral/flutter_lldbinit @@ -0,0 +1,5 @@ +# +# Generated file, do not edit. +# + +command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/packages/graphql/CANCELLATION_USAGE.md b/packages/graphql/CANCELLATION_USAGE.md new file mode 100644 index 000000000..69ebbb7c5 --- /dev/null +++ b/packages/graphql/CANCELLATION_USAGE.md @@ -0,0 +1,255 @@ +# Cancellation Support + +The graphql package now supports cancellation of `query` and `mutate` +operations. This allows you to cancel in-flight operations when they're no +longer needed (e.g., when a user navigates away or cancels an action). + +## Basic Usage + +### Option 1: Using CancellationToken directly + +```dart +import 'package:graphql/client.dart'; + +// Create a cancellation token +final cancellationToken = CancellationToken(); + +// Execute a query with the cancellation token +final resultFuture = client.query( + QueryOptions( + document: gql(r''' + query GetUser($id: ID!) { + user(id: $id) { + id + name + email + } + } + '''), + variables: {'id': '123'}, + cancellationToken: cancellationToken, + ), +); + +// Later, if you need to cancel the operation: +cancellationToken.cancel(); + +// The resultFuture will complete with a QueryResult containing +// a CancelledException +try { + final result = await resultFuture; + print('Result: ${result.data}'); +} catch (e) { + if (e is OperationException && + e.linkException is CancelledException) { + print('Operation was cancelled'); + } +} + +// Don't forget to dispose the token when done +cancellationToken.dispose(); +``` + +### Option 2: Using the convenience methods + +The package provides `queryCancellable` and `mutateCancellable` convenience +methods that automatically create a `CancellationToken` for you: + +```dart +import 'package:graphql/client.dart'; + +// Execute a cancellable query +final operation = client.queryCancellable( + QueryOptions( + document: gql(r''' + query GetPosts { + posts { + id + title + content + } + } + '''), + ), +); + +// Access the result future +final resultFuture = operation.result; + +// Cancel the operation if needed +operation.cancel(); + +// Handle the result +try { + final result = await resultFuture; + print('Posts: ${result.data}'); +} catch (e) { + if (e is OperationException && + e.linkException is CancelledException) { + print('Query was cancelled'); + } +} +``` + +## Mutation Example + +```dart +import 'package:graphql/client.dart'; + +// Execute a cancellable mutation +final operation = client.mutateCancellable( + MutationOptions( + document: gql(r''' + mutation CreatePost($title: String!, $content: String!) { + createPost(title: $title, content: $content) { + id + title + content + } + } + '''), + variables: { + 'title': 'New Post', + 'content': 'This is the content', + }, + ), +); + +// Cancel if user navigates away +// operation.cancel(); + +try { + final result = await operation.result; + print('Created post: ${result.data}'); +} catch (e) { + if (e is OperationException && + e.linkException is CancelledException) { + print('Mutation was cancelled'); + } +} +``` + +## Use Cases + +### 1. User Navigation + +Cancel pending requests when a user navigates away from a page: + +```dart +class MyWidget extends StatefulWidget { + @override + _MyWidgetState createState() => _MyWidgetState(); +} + +class _MyWidgetState extends State { + CancellableOperation? _operation; + + @override + void initState() { + super.initState(); + _operation = client.queryCancellable( + QueryOptions(document: gql('query { ... }')), + ); + } + + @override + void dispose() { + // Cancel the operation when the widget is disposed + _operation?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _operation?.result, + builder: (context, snapshot) { + // Build your UI + }, + ); + } +} +``` + +### 2. Search Debouncing + +Cancel previous search requests when a new one is initiated: + +```dart +CancellableOperation? _searchOperation; + +void search(String query) { + // Cancel the previous search + _searchOperation?.cancel(); + + // Start a new search + _searchOperation = client.queryCancellable( + QueryOptions( + document: gql(r''' + query Search($query: String!) { + search(query: $query) { + id + name + } + } + '''), + variables: {'query': query}, + ), + ); + + _searchOperation!.result.then((result) { + // Handle search results + }).catchError((e) { + if (e is OperationException && + e.linkException is CancelledException) { + // Search was cancelled, ignore + return; + } + // Handle other errors + }); +} +``` + +### 3. Timeout with Custom Message + +Combine with timeouts for better control: + +```dart +final cancellationToken = CancellationToken(); + +// Set a custom timeout +Timer(Duration(seconds: 10), () { + cancellationToken.cancel(); +}); + +final result = await client.query( + QueryOptions( + document: gql('query { ... }'), + cancellationToken: cancellationToken, + ), +); +``` + +## Important Notes + +1. **Disposing CancellationTokens**: When using `CancellationToken` directly, + remember to call `dispose()` when you're done with it to clean up resources. + +2. **Convenience Methods**: The `queryCancellable` and `mutateCancellable` + methods automatically create and manage `CancellationToken` instances for + you. + +3. **Error Handling**: Cancelled operations will complete with an + `OperationException` containing a `CancelledException` as the + `linkException`. + +4. **Network Cleanup**: Cancelling an operation will abort the underlying HTTP + request. The `HttpLink` provided by this package supports true network + cancellation on both IO (using `dart:io` `HttpClient.abort()`) and Web + platforms (using `XMLHttpRequest.abort()`). In Chrome DevTools, you will see + cancelled requests show as "(canceled)" in the Network tab. Note: If you + provide a custom `http.Client` to `HttpLink`, cancellation may not work + unless your client supports it. + +5. **Cache Updates**: Cancelled operations will not update the cache with any + results they may have received before cancellation. diff --git a/packages/graphql/README.md b/packages/graphql/README.md index 333bace91..9eb095b29 100644 --- a/packages/graphql/README.md +++ b/packages/graphql/README.md @@ -43,6 +43,7 @@ As of `v4`, it is built on foundational libraries from the [gql-dart project], i - [Mutations](#mutations) - [GraphQL Upload](#graphql-upload) - [Subscriptions](#subscriptions) + - [Cancellation](#cancellation) - [`client.watchQuery` and `ObservableQuery`](#clientwatchquery-and-observablequery) - [`client.watchMutation`](#clientwatchmutation) - [Normalization](#normalization) @@ -513,6 +514,137 @@ and if the token changed it will be reconnect with the new token otherwise it wi +### Cancellation + +GraphQL operations can be cancelled before they complete, allowing you to abort network requests that are no longer needed. This is useful when navigating away from a screen, implementing search-as-you-type with debouncing, or managing resource-intensive operations. + +#### Using CancellationToken Directly + +Create a `CancellationToken` and pass it to your operation options: + +```dart +import 'package:graphql/client.dart'; + +final cancellationToken = CancellationToken(); + +// Start a query +final resultFuture = client.query( + QueryOptions( + document: gql(readRepositories), + variables: {'nRepositories': 50}, + cancellationToken: cancellationToken, + ), +); + +// Cancel it before completion +cancellationToken.cancel(); + +// The result will contain a CancelledException +final result = await resultFuture; +if (result.hasException) { + if (result.exception!.linkException is CancelledException) { + print('Operation was cancelled'); + } +} + +// Clean up when done +cancellationToken.dispose(); +``` + +#### Using Convenience Methods + +The `queryCancellable` and `mutateCancellable` methods automatically create and manage the `CancellationToken`: + +```dart +// Query with automatic token management +final operation = client.queryCancellable( + QueryOptions( + document: gql(readRepositories), + variables: {'nRepositories': 50}, + ), +); + +// Cancel anytime +operation.cancel(); + +// Get the result +final result = await operation.result; +``` + +```dart +// Mutation with automatic token management +final operation = client.mutateCancellable( + MutationOptions( + document: gql(addStar), + variables: {'starrableId': repositoryID}, + ), +); + +// Cancel if needed +operation.cancel(); + +final result = await operation.result; +``` + +#### Practical Use Cases + +**Search-as-you-type with debouncing:** +```dart +CancellableOperation? currentSearch; + +void onSearchTextChanged(String query) { + // Cancel previous search + currentSearch?.cancel(); + + // Start new search + currentSearch = client.queryCancellable( + QueryOptions( + document: gql(searchQuery), + variables: {'query': query}, + ), + ); + + currentSearch!.result.then((result) { + if (!result.hasException) { + updateSearchResults(result.data); + } + }); +} +``` + +**Navigation cleanup:** +```dart +class MyWidget extends StatefulWidget { + @override + _MyWidgetState createState() => _MyWidgetState(); +} + +class _MyWidgetState extends State { + CancellableOperation? operation; + + @override + void initState() { + super.initState(); + operation = client.queryCancellable( + QueryOptions(document: gql(myQuery)), + ); + } + + @override + void dispose() { + operation?.cancel(); // Cancel when leaving screen + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Your widget tree + } +} +``` + +For more details, see [CANCELLATION_USAGE.md](./CANCELLATION_USAGE.md). + ### `client.watchQuery` and `ObservableQuery` [`client.watchQuery`](https://pub.dev/documentation/graphql/latest/graphql/GraphQLClient/watchQuery.html) diff --git a/packages/graphql/lib/src/core/_base_options.dart b/packages/graphql/lib/src/core/_base_options.dart index de2eb0ccb..25ae3da06 100644 --- a/packages/graphql/lib/src/core/_base_options.dart +++ b/packages/graphql/lib/src/core/_base_options.dart @@ -3,6 +3,7 @@ import 'package:gql/ast.dart'; import 'package:graphql/client.dart'; import 'package:graphql/src/core/result_parser.dart'; +import 'package:graphql/src/core/cancellation_token.dart'; import 'package:meta/meta.dart'; TParsed unprovidedParserFn(_d) => throw UnimplementedError( @@ -24,6 +25,7 @@ abstract class BaseOptions { CacheRereadPolicy? cacheRereadPolicy, this.optimisticResult, this.queryRequestTimeout, + this.cancellationToken, }) : policies = Policies( fetch: fetchPolicy, error: errorPolicy, @@ -64,6 +66,12 @@ abstract class BaseOptions { /// Override default query timeout final Duration? queryRequestTimeout; + /// Token that can be used to cancel the operation. + /// + /// When the token is cancelled, the operation will be terminated and complete + /// with a [CancelledException]. + final CancellationToken? cancellationToken; + // TODO consider inverting this relationship /// Resolve these options into a request Request get asRequest => Request( @@ -85,6 +93,7 @@ abstract class BaseOptions { context, parserFn, queryRequestTimeout, + cancellationToken, ]; OperationType get type { diff --git a/packages/graphql/lib/src/core/cancellation_token.dart b/packages/graphql/lib/src/core/cancellation_token.dart new file mode 100644 index 000000000..6c99a6a69 --- /dev/null +++ b/packages/graphql/lib/src/core/cancellation_token.dart @@ -0,0 +1,92 @@ +import 'dart:async'; +import 'package:gql_exec/gql_exec.dart'; + +/// A token that can be used to cancel an in-flight GraphQL operation. +/// +/// Create a [CancellationToken] and pass it to operations like [GraphQLClient.query] +/// or [GraphQLClient.mutate] to enable cancellation of those operations. +/// +/// {@tool snippet} +/// Basic usage +/// +/// ```dart +/// final cancellationToken = CancellationToken(); +/// +/// // Start a query +/// final resultFuture = client.query( +/// QueryOptions( +/// document: gql('query { ... }'), +/// cancellationToken: cancellationToken, +/// ), +/// ); +/// +/// // Later, cancel the operation +/// cancellationToken.cancel(); +/// +/// // The resultFuture will complete with a QueryResult containing +/// // a CancelledException +/// ``` +/// {@end-tool} +class CancellationToken { + bool _isCancelled = false; + final _controller = StreamController.broadcast(); + + /// Whether this token has been cancelled. + bool get isCancelled => _isCancelled; + + /// A stream that emits when cancellation is requested. + Stream get onCancel => _controller.stream; + + /// Cancel the operation associated with this token. + /// + /// This will cause any in-flight operation to be terminated and complete + /// with a [CancelledException]. + /// + /// Calling [cancel] multiple times has no additional effect. + void cancel() { + if (!_isCancelled) { + _isCancelled = true; + _controller.add(null); + } + } + + /// Dispose of this token's resources. + /// + /// After calling [dispose], this token should not be used again. + void dispose() { + _controller.close(); + } +} + +/// Result wrapper that includes both the result future and a cancellation token. +/// +/// This allows you to cancel the operation if needed. +class CancellableOperation { + /// The future that will complete with the operation result. + final Future result; + + /// The cancellation token that can be used to cancel this operation. + final CancellationToken cancellationToken; + + CancellableOperation({ + required this.result, + required this.cancellationToken, + }); + + /// Cancel this operation. + /// + /// This is a convenience method that calls [CancellationToken.cancel]. + void cancel() { + cancellationToken.cancel(); + } +} + +/// Context entry that holds a [CancellationToken]. +class CancellationContextEntry extends ContextEntry { + final CancellationToken token; + + const CancellationContextEntry(this.token); + + @override + List get fieldsForEquality => [token]; +} diff --git a/packages/graphql/lib/src/core/core.dart b/packages/graphql/lib/src/core/core.dart index a8e64a905..38f503c02 100644 --- a/packages/graphql/lib/src/core/core.dart +++ b/packages/graphql/lib/src/core/core.dart @@ -1,6 +1,7 @@ export 'package:gql_exec/gql_exec.dart'; export 'package:gql_link/gql_link.dart'; +export 'package:graphql/src/core/cancellation_token.dart'; export 'package:graphql/src/core/observable_query.dart'; export 'package:graphql/src/core/query_manager.dart'; export 'package:graphql/src/core/query_options.dart'; diff --git a/packages/graphql/lib/src/core/mutation_options.dart b/packages/graphql/lib/src/core/mutation_options.dart index 018247716..10d81cf9e 100644 --- a/packages/graphql/lib/src/core/mutation_options.dart +++ b/packages/graphql/lib/src/core/mutation_options.dart @@ -31,6 +31,7 @@ class MutationOptions extends BaseOptions { this.onError, ResultParserFn? parserFn, Duration? queryRequestTimeout, + CancellationToken? cancellationToken, }) : super( fetchPolicy: fetchPolicy, errorPolicy: errorPolicy, @@ -42,6 +43,7 @@ class MutationOptions extends BaseOptions { optimisticResult: optimisticResult, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); final OnMutationCompleted? onCompleted; @@ -71,6 +73,7 @@ class MutationOptions extends BaseOptions { onError: onError, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); WatchQueryOptions asWatchQueryOptions() => @@ -85,6 +88,7 @@ class MutationOptions extends BaseOptions { context: context, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); } diff --git a/packages/graphql/lib/src/core/query_manager.dart b/packages/graphql/lib/src/core/query_manager.dart index fd23bbea9..6b5c0be23 100644 --- a/packages/graphql/lib/src/core/query_manager.dart +++ b/packages/graphql/lib/src/core/query_manager.dart @@ -11,6 +11,7 @@ import 'package:gql_link/gql_link.dart' show Link; import 'package:graphql/src/cache/cache.dart'; import 'package:graphql/src/core/observable_query.dart'; import 'package:graphql/src/core/_base_options.dart'; +import 'package:graphql/src/core/cancellation_token.dart'; import 'package:graphql/src/core/mutation_options.dart'; import 'package:graphql/src/core/query_options.dart'; import 'package:graphql/src/core/query_result.dart'; @@ -271,6 +272,13 @@ class QueryManager { bool rereadFromCache = false; + // Add cancellation token to context if present + if (options.cancellationToken != null) { + request = request.updateContextEntry( + (entry) => CancellationContextEntry(options.cancellationToken!), + ); + } + try { // execute the request through the provided link(s) Stream responseStream = link.request(request); @@ -282,7 +290,57 @@ class QueryManager { if (timeout case final Duration timeout) { responseStream = responseStream.timeout(timeout); } - response = await responseStream.first; + + // Handle cancellation if a token is provided + final cancellationToken = options.cancellationToken; + if (cancellationToken != null) { + // Check if already cancelled + if (cancellationToken.isCancelled) { + throw CancelledException('Operation was cancelled'); + } + + final completer = Completer(); + StreamSubscription? subscription; + StreamSubscription? cancellationSubscription; + + cancellationSubscription = cancellationToken.onCancel.listen((_) { + // Complete the completer with an error first + // The HttpLink will detect cancellation and abort the underlying request + if (!completer.isCompleted) { + completer.completeError( + CancelledException('Operation was cancelled'), + StackTrace.current, + ); + } + }); + + subscription = responseStream.listen( + (response) { + if (!completer.isCompleted) { + completer.complete(response); + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!completer.isCompleted) { + completer.completeError(error, stackTrace); + } + }, + onDone: () { + cancellationSubscription?.cancel(); + }, + cancelOnError: true, + ); + + try { + response = await completer.future; + } finally { + // Clean up subscriptions + await cancellationSubscription.cancel(); + await subscription.cancel(); + } + } else { + response = await responseStream.first; + } queryResult = mapFetchResultToQueryResult( response, diff --git a/packages/graphql/lib/src/core/query_options.dart b/packages/graphql/lib/src/core/query_options.dart index f9634b3da..b16cef9aa 100644 --- a/packages/graphql/lib/src/core/query_options.dart +++ b/packages/graphql/lib/src/core/query_options.dart @@ -30,6 +30,7 @@ class QueryOptions extends BaseOptions { Duration? queryRequestTimeout, this.onComplete, this.onError, + CancellationToken? cancellationToken, }) : super( fetchPolicy: fetchPolicy, errorPolicy: errorPolicy, @@ -41,6 +42,7 @@ class QueryOptions extends BaseOptions { optimisticResult: optimisticResult, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); final OnQueryComplete? onComplete; @@ -73,6 +75,7 @@ class QueryOptions extends BaseOptions { Duration? queryRequestTimeout, OnQueryComplete? onComplete, OnQueryError? onError, + CancellationToken? cancellationToken, }) => QueryOptions( document: document ?? this.document, @@ -88,6 +91,7 @@ class QueryOptions extends BaseOptions { queryRequestTimeout: queryRequestTimeout ?? this.queryRequestTimeout, onComplete: onComplete ?? this.onComplete, onError: onError ?? this.onError, + cancellationToken: cancellationToken ?? this.cancellationToken, ); QueryOptions withFetchMoreOptions( @@ -121,6 +125,7 @@ class QueryOptions extends BaseOptions { optimisticResult: optimisticResult, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); QueryOptions copyWithPolicies(Policies policies) => QueryOptions( @@ -135,6 +140,7 @@ class QueryOptions extends BaseOptions { context: context, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); } @@ -152,6 +158,7 @@ class SubscriptionOptions Context? context, ResultParserFn? parserFn, Duration? queryRequestTimeout, + CancellationToken? cancellationToken, }) : super( fetchPolicy: fetchPolicy, errorPolicy: errorPolicy, @@ -163,6 +170,7 @@ class SubscriptionOptions optimisticResult: optimisticResult, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); SubscriptionOptions copyWithPolicies(Policies policies) => SubscriptionOptions( @@ -176,6 +184,7 @@ class SubscriptionOptions context: context, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); } @@ -196,6 +205,7 @@ class WatchQueryOptions extends QueryOptions { Context? context, ResultParserFn? parserFn, Duration? queryRequestTimeout, + CancellationToken? cancellationToken, }) : eagerlyFetchResults = eagerlyFetchResults ?? fetchResults, super( document: document, @@ -209,6 +219,7 @@ class WatchQueryOptions extends QueryOptions { optimisticResult: optimisticResult, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); /// Whether or not to fetch results every time a new listener is added. @@ -250,6 +261,7 @@ class WatchQueryOptions extends QueryOptions { Context? context, ResultParserFn? parserFn, Duration? queryRequestTimeout, + CancellationToken? cancellationToken, }) => WatchQueryOptions( document: document ?? this.document, @@ -267,6 +279,7 @@ class WatchQueryOptions extends QueryOptions { context: context ?? this.context, parserFn: parserFn ?? this.parserFn, queryRequestTimeout: queryRequestTimeout ?? this.queryRequestTimeout, + cancellationToken: cancellationToken ?? this.cancellationToken, ); WatchQueryOptions copyWithFetchPolicy( @@ -287,6 +300,7 @@ class WatchQueryOptions extends QueryOptions { context: context, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); WatchQueryOptions copyWithPolicies( Policies policies, @@ -306,6 +320,7 @@ class WatchQueryOptions extends QueryOptions { context: context, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); WatchQueryOptions copyWithPollInterval(Duration? pollInterval) => @@ -324,6 +339,7 @@ class WatchQueryOptions extends QueryOptions { context: context, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); WatchQueryOptions copyWithVariables( @@ -343,6 +359,7 @@ class WatchQueryOptions extends QueryOptions { context: context, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); WatchQueryOptions copyWithOptimisticResult( @@ -362,6 +379,7 @@ class WatchQueryOptions extends QueryOptions { context: context, parserFn: parserFn, queryRequestTimeout: queryRequestTimeout, + cancellationToken: cancellationToken, ); } diff --git a/packages/graphql/lib/src/exceptions/exceptions_next.dart b/packages/graphql/lib/src/exceptions/exceptions_next.dart index 78420fa7e..e90381193 100644 --- a/packages/graphql/lib/src/exceptions/exceptions_next.dart +++ b/packages/graphql/lib/src/exceptions/exceptions_next.dart @@ -7,6 +7,17 @@ import 'package:meta/meta.dart'; export 'package:gql_exec/gql_exec.dart' show GraphQLError; export 'package:normalize/normalize.dart' show PartialDataException; +/// Exception thrown when an operation is cancelled via a [CancellationToken]. +@immutable +class CancelledException extends LinkException { + CancelledException(this.message) : super(null, null); + + final String message; + + @override + String toString() => 'CancelledException($message)'; +} + /// A failure to find a response from the cache. /// /// Can occur when `cacheOnly=true`, or when the [request] was just written diff --git a/packages/graphql/lib/src/graphql_client.dart b/packages/graphql/lib/src/graphql_client.dart index ae31eb495..2696c827e 100644 --- a/packages/graphql/lib/src/graphql_client.dart +++ b/packages/graphql/lib/src/graphql_client.dart @@ -191,6 +191,102 @@ class GraphQLClient implements GraphQLDataProxy { return await queryManager.mutate(options.copyWithPolicies(policies)); } + /// Executes a query that can be cancelled via the returned [CancellableOperation]. + /// + /// This is a convenience method that creates a [CancellationToken] for you and + /// returns both the result future and the token wrapped in a [CancellableOperation]. + /// + /// {@tool snippet} + /// Basic usage + /// + /// ```dart + /// final operation = client.queryCancellable( + /// QueryOptions( + /// document: gql('query { ... }'), + /// ), + /// ); + /// + /// // Later, if you need to cancel: + /// operation.cancel(); + /// + /// // The result future will complete with a CancelledException + /// try { + /// final result = await operation.result; + /// } catch (e) { + /// if (e is CancelledException) { + /// // Handle cancellation + /// } + /// } + /// ``` + /// {@end-tool} + CancellableOperation> queryCancellable( + QueryOptions options, + ) { + final cancellationToken = CancellationToken(); + final modifiedOptions = options.copyWithOptions( + cancellationToken: cancellationToken, + ); + return CancellableOperation( + result: query(modifiedOptions), + cancellationToken: cancellationToken, + ); + } + + /// Executes a mutation that can be cancelled via the returned [CancellableOperation]. + /// + /// This is a convenience method that creates a [CancellationToken] for you and + /// returns both the result future and the token wrapped in a [CancellableOperation]. + /// + /// {@tool snippet} + /// Basic usage + /// + /// ```dart + /// final operation = client.mutateCancellable( + /// MutationOptions( + /// document: gql('mutation { ... }'), + /// ), + /// ); + /// + /// // Later, if you need to cancel: + /// operation.cancel(); + /// + /// // The result future will complete with a CancelledException + /// try { + /// final result = await operation.result; + /// } catch (e) { + /// if (e is CancelledException) { + /// // Handle cancellation + /// } + /// } + /// ``` + /// {@end-tool} + CancellableOperation> mutateCancellable( + MutationOptions options, + ) { + final cancellationToken = CancellationToken(); + final policies = defaultPolicies.mutate.withOverrides(options.policies); + final modifiedOptions = MutationOptions( + document: options.document, + operationName: options.operationName, + variables: options.variables, + fetchPolicy: policies.fetch, + errorPolicy: policies.error, + cacheRereadPolicy: policies.cacheReread, + context: options.context, + optimisticResult: options.optimisticResult, + onCompleted: options.onCompleted, + update: options.update, + onError: options.onError, + parserFn: options.parserFn, + queryRequestTimeout: options.queryRequestTimeout, + cancellationToken: cancellationToken, + ); + return CancellableOperation( + result: queryManager.mutate(modifiedOptions), + cancellationToken: cancellationToken, + ); + } + /// This subscribes to a GraphQL subscription according to the options specified and returns a /// [Stream] which either emits received data or an error. /// diff --git a/packages/graphql/lib/src/links/gql_links.dart b/packages/graphql/lib/src/links/gql_links.dart index 4a83fded2..39d5ad5b6 100644 --- a/packages/graphql/lib/src/links/gql_links.dart +++ b/packages/graphql/lib/src/links/gql_links.dart @@ -1,4 +1,5 @@ export 'package:gql_link/gql_link.dart'; -export 'package:gql_http_link/gql_http_link.dart'; +export 'package:gql_http_link/gql_http_link.dart' hide HttpLink; +export 'package:graphql/src/links/http_link/http_link.dart'; export 'package:gql_error_link/gql_error_link.dart'; export 'package:gql_dedupe_link/gql_dedupe_link.dart'; diff --git a/packages/graphql/lib/src/links/http_link/client_interface.dart b/packages/graphql/lib/src/links/http_link/client_interface.dart new file mode 100644 index 000000000..aafdc8cd2 --- /dev/null +++ b/packages/graphql/lib/src/links/http_link/client_interface.dart @@ -0,0 +1,13 @@ +import 'dart:async'; +import 'package:http/http.dart' as http; +import 'package:graphql/src/core/cancellation_token.dart'; + +abstract class CancellableHttpClient { + Future send({ + required Uri uri, + required String method, + Map? headers, + Object? body, + CancellationToken? cancellationToken, + }); +} diff --git a/packages/graphql/lib/src/links/http_link/http_link.dart b/packages/graphql/lib/src/links/http_link/http_link.dart new file mode 100644 index 000000000..e00035cc6 --- /dev/null +++ b/packages/graphql/lib/src/links/http_link/http_link.dart @@ -0,0 +1,160 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:gql_exec/gql_exec.dart'; +import 'package:gql_link/gql_link.dart'; +import 'package:graphql/src/core/cancellation_token.dart'; +import 'package:gql_http_link/gql_http_link.dart' + show HttpLinkHeaders, HttpLinkResponseContext, HttpLinkServerException; + +import 'client_interface.dart'; +// Conditional import +import 'io_client.dart' if (dart.library.html) 'web_client.dart'; + +export 'package:gql_http_link/gql_http_link.dart' show HttpLinkHeaders; + +class HttpLink extends Link { + HttpLink( + this.uri, { + this.defaultHeaders = const {}, + this.httpClient, + this.serializer = const RequestSerializer(), + this.parser = const ResponseParser(), + }) : _cancellableClient = CancellableHttpClientImpl(); + + final String uri; + final Map defaultHeaders; + final http.Client? httpClient; + final RequestSerializer serializer; + final ResponseParser parser; + + final CancellableHttpClient _cancellableClient; + + @override + Stream request(Request request, [NextLink? forward]) async* { + final http.Request httpRequest = await _convertRequest(request); + + // Extract cancellation token from context + CancellationToken? cancellationToken; + final contextEntry = request.context.entry(); + if (contextEntry != null) { + cancellationToken = contextEntry.token; + } + + try { + final http.Response response; + if (httpClient != null) { + // Fallback to provided client (no cancellation support unless client supports it internally?) + // We can't force cancellation on an arbitrary http.Client + final streamedResponse = await httpClient!.send(httpRequest); + response = await http.Response.fromStream(streamedResponse); + } else { + // Use our cancellable client + response = await _cancellableClient.send( + uri: httpRequest.url, + method: httpRequest.method, + headers: httpRequest.headers, + body: httpRequest.bodyBytes, // or body + cancellationToken: cancellationToken, + ); + } + + if (response.statusCode >= 300 || + (response.statusCode < 200 && response.statusCode != 0)) { + throw HttpLinkServerException( + response: response, + parsedResponse: Response( + response: {}, + context: Context().withEntry( + HttpLinkResponseContext( + statusCode: response.statusCode, + headers: response.headers, + ), + ), + ), + ); + } + + final Map body; + try { + final dynamic decoded = json.decode(response.body); + body = decoded is Map ? decoded : {}; + } catch (e) { + throw HttpLinkServerException( + response: response, + parsedResponse: Response( + response: {}, + context: Context().withEntry( + HttpLinkResponseContext( + statusCode: response.statusCode, + headers: response.headers, + ), + ), + ), + ); + } + + final gqlResponse = parser.parseResponse(body); + + yield Response( + data: gqlResponse.data, + errors: gqlResponse.errors, + context: gqlResponse.context.withEntry( + HttpLinkResponseContext( + statusCode: response.statusCode, + headers: response.headers, + ), + ), + response: body, + ); + } on http.ClientException catch (e) { + // Check if this was a cancellation - if so, just return without yielding + // The QueryManager will handle the cancellation via its own mechanism + if (cancellationToken != null && cancellationToken.isCancelled) { + return; // Don't throw, just end the stream + } + // Check if the error message indicates cancellation + if (e.message.contains('abort') || + e.message.contains('cancel') || + e.message.contains('cancelled')) { + return; // Don't throw, just end the stream + } + throw ServerException( + originalException: e, + parsedResponse: null, + ); + } catch (e) { + // Check if this was a cancellation - if so, just return without yielding + if (cancellationToken != null && cancellationToken.isCancelled) { + return; // Don't throw, just end the stream + } + throw ServerException( + originalException: e, + parsedResponse: null, + ); + } + } + + Future _convertRequest(Request request) async { + final body = await serializer.serializeRequest(request); + final httpRequest = http.Request('POST', Uri.parse(uri)); + + httpRequest.body = json.encode(body); + + httpRequest.headers.addAll({ + 'Content-Type': 'application/json', + 'Accept': '*/*', + }); + httpRequest.headers.addAll(defaultHeaders); + + // Apply headers from context + final HttpLinkHeaders? contextHeaders = + request.context.entry(); + if (contextHeaders != null) { + httpRequest.headers.addAll(contextHeaders.headers); + } + + return httpRequest; + } +} diff --git a/packages/graphql/lib/src/links/http_link/io_client.dart b/packages/graphql/lib/src/links/http_link/io_client.dart new file mode 100644 index 000000000..4065fd60a --- /dev/null +++ b/packages/graphql/lib/src/links/http_link/io_client.dart @@ -0,0 +1,119 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'package:graphql/src/core/cancellation_token.dart'; +import 'client_interface.dart'; + +class CancellableHttpClientImpl implements CancellableHttpClient { + @override + Future send({ + required Uri uri, + required String method, + Map? headers, + Object? body, + CancellationToken? cancellationToken, + }) async { + // Create a new HttpClient for each request so we can close it on cancellation + final client = HttpClient(); + + final HttpClientRequest request = await client.openUrl(method, uri); + + if (headers != null) { + headers.forEach((key, value) { + request.headers.add(key, value); + }); + } + + if (body != null) { + if (body is String) { + request.write(body); + } else if (body is List) { + request.add(body); + } + } + + final completer = Completer(); + StreamSubscription? cancellationSubscription; + bool isCancelled = false; + + if (cancellationToken != null) { + cancellationSubscription = cancellationToken.onCancel.listen((_) { + isCancelled = true; + // Abort the request if it hasn't been sent yet + request.abort(); + // Force close the client to terminate any in-flight connections + client.close(force: true); + if (!completer.isCompleted) { + completer.completeError( + http.ClientException('Request cancelled', uri), + ); + } + }); + } + + // Don't start the request if already cancelled + if (cancellationToken?.isCancelled == true) { + client.close(force: true); + throw http.ClientException('Request cancelled', uri); + } + + try { + final HttpClientResponse response = await request.close(); + + // Check if cancelled while waiting for response headers + if (isCancelled) { + response.detachSocket().then((socket) => socket.destroy()); + throw http.ClientException('Request cancelled', uri); + } + + // Collect response body, checking for cancellation + final List bytes = []; + await for (final chunk in response) { + if (isCancelled) { + response.detachSocket().then((socket) => socket.destroy()); + throw http.ClientException('Request cancelled', uri); + } + bytes.addAll(chunk); + } + + final result = http.Response.bytes( + bytes, + response.statusCode, + headers: _convertHeaders(response.headers), + request: http.Request(method, uri), + isRedirect: response.isRedirect, + persistentConnection: response.persistentConnection, + reasonPhrase: response.reasonPhrase, + ); + + if (!completer.isCompleted) { + completer.complete(result); + } + } on HttpException catch (e) { + if (!completer.isCompleted) { + completer.completeError(http.ClientException(e.message, uri)); + } + } catch (e) { + if (!completer.isCompleted) { + if (e is http.ClientException) { + completer.completeError(e); + } else { + completer.completeError(http.ClientException(e.toString(), uri)); + } + } + } finally { + cancellationSubscription?.cancel(); + client.close(); + } + + return completer.future; + } + + Map _convertHeaders(HttpHeaders headers) { + final Map result = {}; + headers.forEach((key, values) { + result[key] = values.join(','); + }); + return result; + } +} diff --git a/packages/graphql/lib/src/links/http_link/web_client.dart b/packages/graphql/lib/src/links/http_link/web_client.dart new file mode 100644 index 000000000..4ad77eeda --- /dev/null +++ b/packages/graphql/lib/src/links/http_link/web_client.dart @@ -0,0 +1,86 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:typed_data'; +import 'package:web/web.dart' as web; +import 'package:http/http.dart' as http; +import 'package:graphql/src/core/cancellation_token.dart'; +import 'client_interface.dart'; + +class CancellableHttpClientImpl implements CancellableHttpClient { + @override + Future send({ + required Uri uri, + required String method, + Map? headers, + Object? body, + CancellationToken? cancellationToken, + }) async { + final request = web.XMLHttpRequest(); + request.open(method, uri.toString()); + + if (headers != null) { + headers.forEach((key, value) { + request.setRequestHeader(key, value); + }); + } + + StreamSubscription? cancellationSubscription; + if (cancellationToken != null) { + cancellationSubscription = cancellationToken.onCancel.listen((_) { + request.abort(); + }); + } + + final completer = Completer(); + + request.onload = ((web.Event e) { + completer.complete(http.Response( + request.responseText, + request.status, + headers: _parseHeaders(request.getAllResponseHeaders()), + request: http.Request(method, uri), + )); + }).toJS; + + request.onerror = ((web.Event e) { + completer + .completeError(http.ClientException('XMLHttpRequest error', uri)); + }).toJS; + + request.onabort = ((web.Event e) { + completer.completeError(http.ClientException('Request cancelled', uri)); + }).toJS; + + if (body != null) { + if (body is String) { + request.send(body.toJS); + } else if (body is List) { + request.send(Uint8List.fromList(body).toJS); + } else { + request.send(); + } + } else { + request.send(); + } + + try { + return await completer.future; + } finally { + cancellationSubscription?.cancel(); + } + } + + Map _parseHeaders(String headers) { + final Map result = {}; + final lines = headers.split('\r\n'); + for (final line in lines) { + final index = line.indexOf(':'); + if (index > 0) { + final key = line.substring(0, index).trim(); + final value = line.substring(index + 1).trim(); + result[key] = value; + } + } + return result; + } +} diff --git a/packages/graphql/pubspec.yaml b/packages/graphql/pubspec.yaml index 7f5fd5046..0a71b7e65 100644 --- a/packages/graphql/pubspec.yaml +++ b/packages/graphql/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: stream_channel: ^2.1.0 rxdart: '>=0.27.1 <0.29.0' uuid: ^4.0.0 + web: ^1.1.1 dev_dependencies: async: ^2.5.0 diff --git a/packages/graphql/test/cancellation_test.dart b/packages/graphql/test/cancellation_test.dart new file mode 100644 index 000000000..38c1a0d8f --- /dev/null +++ b/packages/graphql/test/cancellation_test.dart @@ -0,0 +1,213 @@ +import 'package:gql/language.dart'; +import 'package:graphql/client.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'helpers.dart'; + +void main() { + late MockLink link; + late GraphQLClient client; + + setUp(() { + link = MockLink(); + + client = GraphQLClient( + cache: getTestCache(), + link: link, + ); + }); + + group('Cancellation', () { + test('query can be cancelled with CancellationToken', () async { + final cancellationToken = CancellationToken(); + + when(link.request(any)).thenAnswer( + (_) => Stream.fromFuture( + Future.delayed( + Duration(milliseconds: 500), + () => Response( + data: {'test': 'data'}, + response: {}, + ), + ), + ), + ); + + final resultFuture = client.query( + QueryOptions( + document: parseString('query { test }'), + fetchPolicy: FetchPolicy.networkOnly, + cancellationToken: cancellationToken, + ), + ); + + // Cancel after a short delay + await Future.delayed(Duration(milliseconds: 10)); + cancellationToken.cancel(); + + final result = await resultFuture; + expect(result.hasException, isTrue); + expect(result.exception, isA()); + expect(result.exception!.linkException, isA()); + + cancellationToken.dispose(); + }); + + test('queryCancellable returns CancellableOperation', () async { + when(link.request(any)).thenAnswer( + (_) => Stream.fromFuture( + Future.delayed( + Duration(milliseconds: 500), + () => Response( + data: {'test': 'data'}, + response: {}, + ), + ), + ), + ); + + final operation = client.queryCancellable( + QueryOptions( + document: parseString('query { test }'), + fetchPolicy: FetchPolicy.networkOnly, + ), + ); + + expect(operation, isA>()); + expect(operation.cancellationToken, isA()); + + // Cancel after a short delay + await Future.delayed(Duration(milliseconds: 10)); + operation.cancel(); + + final result = await operation.result; + expect(result.hasException, isTrue); + expect(result.exception, isA()); + expect(result.exception!.linkException, isA()); + }); + + test('mutation can be cancelled with CancellationToken', () async { + final cancellationToken = CancellationToken(); + + when(link.request(any)).thenAnswer( + (_) => Stream.fromFuture( + Future.delayed( + Duration(milliseconds: 500), + () => Response( + data: { + 'createItem': {'id': '1'} + }, + response: {}, + ), + ), + ), + ); + + final resultFuture = client.mutate( + MutationOptions( + document: parseString('mutation { createItem { id } }'), + cancellationToken: cancellationToken, + ), + ); + + // Cancel after a short delay + await Future.delayed(Duration(milliseconds: 10)); + cancellationToken.cancel(); + + final result = await resultFuture; + expect(result.hasException, isTrue); + expect(result.exception, isA()); + expect(result.exception!.linkException, isA()); + + cancellationToken.dispose(); + }); + + test('mutateCancellable returns CancellableOperation', () async { + when(link.request(any)).thenAnswer( + (_) => Stream.fromFuture( + Future.delayed( + Duration(milliseconds: 500), + () => Response( + data: { + 'createItem': {'id': '1'} + }, + response: {}, + ), + ), + ), + ); + + final operation = client.mutateCancellable( + MutationOptions( + document: parseString('mutation { createItem { id } }'), + ), + ); + + expect(operation, isA>()); + expect(operation.cancellationToken, isA()); + + // Cancel after a short delay + await Future.delayed(Duration(milliseconds: 10)); + operation.cancel(); + + final result = await operation.result; + expect(result.hasException, isTrue); + expect(result.exception, isA()); + expect(result.exception!.linkException, isA()); + }); + + test('completed query is not affected by late cancellation', () async { + final cancellationToken = CancellationToken(); + + when(link.request(any)).thenAnswer( + (_) => Stream.fromIterable([ + Response( + data: {'test': 'data'}, + response: {}, + ), + ]), + ); + + final result = await client.query( + QueryOptions( + document: parseString('query { test }'), + cancellationToken: cancellationToken, + ), + ); + + // Cancel after completion should not affect the result + cancellationToken.cancel(); + + expect(result.data, equals({'test': 'data'})); + expect(result.hasException, isFalse); + + cancellationToken.dispose(); + }); + + test('CancellationToken.isCancelled returns correct state', () { + final token = CancellationToken(); + + expect(token.isCancelled, isFalse); + + token.cancel(); + + expect(token.isCancelled, isTrue); + + token.dispose(); + }); + + test('cancelling already cancelled token has no effect', () { + final token = CancellationToken(); + + token.cancel(); + expect(token.isCancelled, isTrue); + + // Should not throw + token.cancel(); + expect(token.isCancelled, isTrue); + + token.dispose(); + }); + }); +} diff --git a/packages/graphql/test/http_link_cancellation_test.dart b/packages/graphql/test/http_link_cancellation_test.dart new file mode 100644 index 000000000..1f619ec7e --- /dev/null +++ b/packages/graphql/test/http_link_cancellation_test.dart @@ -0,0 +1,133 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:gql/language.dart'; +import 'package:graphql/client.dart'; +import 'package:test/test.dart'; + +void main() { + group('HttpLink Cancellation', () { + late HttpServer server; + late String serverUrl; + + setUp(() async { + // Create a simple HTTP server that delays response + server = await HttpServer.bind('localhost', 0); + serverUrl = 'http://localhost:${server.port}/graphql'; + + server.listen((request) async { + // Wait for 2 seconds before responding + await Future.delayed(Duration(seconds: 2)); + + request.response.statusCode = 200; + request.response.headers.contentType = ContentType.json; + request.response.write('{"data": {"test": "data"}}'); + await request.response.close(); + }); + }); + + tearDown(() async { + await server.close(force: true); + }); + + test('cancelling request should abort the HTTP connection', () async { + final link = HttpLink(serverUrl); + final client = GraphQLClient( + cache: GraphQLCache(), + link: link, + ); + + final cancellationToken = CancellationToken(); + + // Start the query (don't await yet) + final resultFuture = client.query( + QueryOptions( + document: parseString('query { test }'), + fetchPolicy: FetchPolicy.networkOnly, + cancellationToken: cancellationToken, + ), + ); + + // Cancel after a short delay (before server responds - server delays 2 seconds) + await Future.delayed(Duration(milliseconds: 100)); + cancellationToken.cancel(); + + // Now await the result + final result = await resultFuture; + + // The result should have an exception + expect(result.hasException, isTrue); + expect(result.exception, isA()); + // The linkException should be a CancelledException + expect(result.exception!.linkException, isA()); + + cancellationToken.dispose(); + }); + + test('request completes normally without cancellation', () async { + // Create a fast server for this test + final fastServer = await HttpServer.bind('localhost', 0); + final fastServerUrl = 'http://localhost:${fastServer.port}/graphql'; + + fastServer.listen((request) async { + request.response.statusCode = 200; + request.response.headers.contentType = ContentType.json; + request.response.write('{"data": {"test": "success"}}'); + await request.response.close(); + }); + + try { + final link = HttpLink(fastServerUrl); + final client = GraphQLClient( + cache: GraphQLCache(), + link: link, + ); + + final result = await client.query( + QueryOptions( + document: parseString('query { test }'), + fetchPolicy: FetchPolicy.networkOnly, + ), + ); + + expect(result.hasException, isFalse); + expect(result.data, equals({'test': 'success'})); + } finally { + await fastServer.close(force: true); + } + }); + + test('CancellationContextEntry is passed to link', () async { + bool contextEntryFound = false; + + final testLink = Link.function((request, [forward]) async* { + final entry = request.context.entry(); + contextEntryFound = entry != null; + + yield Response( + data: {'test': 'data'}, + response: {}, + ); + }); + + final client = GraphQLClient( + cache: GraphQLCache(), + link: testLink, + ); + + final cancellationToken = CancellationToken(); + + await client.query( + QueryOptions( + document: parseString('query { test }'), + fetchPolicy: FetchPolicy.networkOnly, + cancellationToken: cancellationToken, + ), + ); + + expect(contextEntryFound, isTrue); + + cancellationToken.dispose(); + }); + }); +} diff --git a/packages/graphql_flutter/README.md b/packages/graphql_flutter/README.md index 8280c0fd6..d9805dfc4 100644 --- a/packages/graphql_flutter/README.md +++ b/packages/graphql_flutter/README.md @@ -54,6 +54,7 @@ This guide is mostly focused on setup, widgets, and flutter-specific considerati - [Cache Write Strictness](../graphql/README.md#write-strictness-and-partialdatapolicy) - [Policies](../graphql/README.md#exceptions) - [Exceptions](../graphql/README.md#exceptions) +- [Operation Cancellation](../graphql/README.md#cancellation) - [AWS AppSync Support](../graphql/README.md#aws-appsync-support) - [GraphQL Upload](../graphql/README.md#graphql-upload) - [Parsing ASTs at build-time](../graphql/README.md#parsing-asts-at-build-time)