Dart / Flutter Client¶
A fully typed GraphQL client generated directly from your schema.
Generated adapters¶
GraphLink generates adapter files alongside the client. Which HTTP adapter is generated is controlled by the httpAdapter option in glink.json. The WebSocket adapter is always generated when your schema has subscriptions.
httpAdapter — controls which HTTP adapter file is generated:
"http"(default) — generatesgraph_link_http_adapter.dartcontainingGraphLinkHttpAdapter. Usespackage:httpwith an optional asyncheadersProviderfor custom headers."dio"— generatesgraph_link_dio_adapter.dartcontainingGraphLinkDioAdapter. Supports an optionaltokenProviderfor Bearer auth, custom Diointerceptors, andBaseOptions. Used automatically byGraphLinkClient.withHttp."none"— no HTTP adapter file is generated. Supply your ownFuture<String> Function(String).
// generated/client/graph_link_dio_adapter.dart
class GraphLinkDioAdapter {
final String url;
final Dio dio;
GraphLinkDioAdapter({
required this.url,
Dio? dio,
Future<String?> Function()? tokenProvider, // adds Authorization: Bearer <token>
List<Interceptor> interceptors = const [],
BaseOptions? options,
});
Future<String> call(String payload) async { /* ... */ }
}
// generated/client/graph_link_http_adapter.dart
class GraphLinkHttpAdapter {
final String url;
final Future<Map<String, String>?>? Function()? headersProvider;
GraphLinkHttpAdapter({
required this.url,
this.headersProvider, // merged into request headers on every call
});
Future<String> call(String payload) async { /* ... */ }
}
// generated/client/graph_link_websocket_adapter.dart
// Always generated when your schema has subscriptions.
class DefaultGraphLinkWebSocketAdapter extends GraphLinkWebSocketAdapter {
DefaultGraphLinkWebSocketAdapter({
required String url,
Future<Map<String, String>?>? Function()? headersProvider,
Duration initialReconnectDelay = const Duration(seconds: 1),
Duration maxReconnectDelay = const Duration(seconds: 30),
});
// Handles graphql-ws protocol, exponential-backoff reconnect,
// and forwards headersProvider result as connection_init payload.
}
All adapter files are generated into your output directory. They carry no external GraphLink dependency and you can edit them freely.
Initializing the client¶
The GraphLinkClient uses named parameters. Three constructors cover every scenario:
import 'generated/client/graph_link_client.dart';
// Uses the generated Dio adapter for HTTP and the generated
// WebSocket adapter for subscriptions.
final client = GraphLinkClient.withHttp(
url: 'http://localhost:8080/graphql',
wsUrl: 'ws://localhost:8080/graphql',
// Optional — adds Authorization: Bearer <token> on every request
tokenProvider: () async => await getAuthToken(),
// Optional — forwarded as connection_init payload for WS auth
wsTokenProvider: () async => await getAuthToken(),
// Optional — defaults to InMemoryGraphLinkCacheStore
// store: MyPersistentCacheStore(),
);
import 'generated/client/graph_link_client.dart';
import 'generated/client/graph_link_http_adapter.dart';
// Bring your own HTTP adapter (or use GraphLinkHttpAdapter).
// The generated WebSocket adapter handles subscriptions.
final client = GraphLinkClient.fromUrl(
adapter: GraphLinkHttpAdapter(
url: 'http://localhost:8080/graphql',
headersProvider: () async => {'Authorization': 'Bearer ${await getToken()}'},
).call,
wsUrl: 'ws://localhost:8080/graphql',
);
import 'generated/client/graph_link_client.dart';
import 'generated/client/graph_link_websocket_adapter.dart';
// Full control — any adapter function, any WebSocket adapter.
final client = GraphLinkClient(
adapter: myHttpAdapterFn, // Future<String> Function(String payload)
wsAdapter: DefaultGraphLinkWebSocketAdapter(
url: 'ws://localhost:8080/graphql',
headersProvider: () async => {'Authorization': 'Bearer ${await getToken()}'},
initialReconnectDelay: const Duration(seconds: 2),
maxReconnectDelay: const Duration(seconds: 60),
),
store: myPersistentCacheStore, // optional
);
Testing made easy
Because the adapter is just a named function parameter, passing a mock in tests requires no HTTP mocking library: adapter: (payload) async => '{"data":{"getCar":{"id":"1","make":"Toyota"}}}'
Queries¶
All queries are accessible via client.queries. Each operation becomes a method with typed parameters and a typed return value.
// getVehicle returns GetVehicleResponse — never null (Vehicle! in schema)
final res = await client.queries.getVehicle(id: '42');
// res.getVehicle is a typed Vehicle object
print(res.getVehicle.brand); // Toyota
print(res.getVehicle.model); // Camry
print(res.getVehicle.year); // 2023
print(res.getVehicle.fuelType); // FuelType.GASOLINE
The generated GetVehicleResponse type looks like this:
class GetVehicleResponse {
final Vehicle getVehicle;
GetVehicleResponse({required this.getVehicle});
static GetVehicleResponse fromJson(Map<String, dynamic> json) {
return GetVehicleResponse(
getVehicle: Vehicle.fromJson(json['getVehicle'] as Map<String, dynamic>),
);
}
}
toJson/fromJson generation is mandatory (v5.0.0+)
Every generated type, input, enum, and interface always gets toJson/fromJson — there is no config flag to suppress it. This matters because identifier normalization can make a field's generated Dart name diverge from its wire name (e.g. a keyword-colliding or non-canonically-cased GraphQL field): code that reads or writes generated objects by field access is always in the normalized name, and only toJson/fromJson know how to bridge back to the wire format.
List queries¶
final res = await client.queries.listVehicles();
// res.listVehicles is List<Vehicle> — fully typed
for (final vehicle in res.listVehicles) {
print('${vehicle.brand} ${vehicle.model} (${vehicle.year})');
}
Nullable queries¶
When the schema declares a query with a nullable return type (no !), the response wrapper field is also nullable:
// Schema: getPerson(id: ID!): Person <-- no ! on Person
final res = await client.queries.getPerson(id: '99');
// res.getPerson is Person? — use null-aware access
if (res.getPerson != null) {
print(res.getPerson!.name);
}
// Or with null-safe chaining
print(res.getPerson?.email ?? 'Not found');
Mutations¶
Mutations live under client.mutations and follow the same pattern. Input types are passed as named parameters:
import 'generated/inputs/add_vehicle_input.dart';
import 'generated/enums/fuel_type.dart';
final added = await client.mutations.addVehicle(
input: AddVehicleInput(
brand: 'Toyota',
model: 'Camry',
year: 2023,
fuelType: FuelType.GASOLINE,
// ownerId is nullable — omit it or pass null
),
);
print(added.addVehicle.id); // server-assigned ID
print(added.addVehicle.brand); // Toyota
The generated AddVehicleInput class enforces required fields at construction time through Dart's named required parameters:
class AddVehicleInput {
final String brand;
final String model;
final int year;
final FuelType fuelType;
final String? ownerId;
AddVehicleInput({
required this.brand, required this.model,
required this.year, required this.fuelType, this.ownerId
});
Map<String, dynamic> toJson() {
return { 'brand': brand, 'model': model, 'year': year,
'fuelType': fuelType.toJson(), 'ownerId': ownerId };
}
}
Subscriptions¶
Subscriptions are available via client.subscriptions and return a Stream. The generated DefaultGraphLinkWebSocketAdapter implements the graphql-ws subprotocol and handles connection init, ping/pong, and reconnect automatically.
final subscription = client.subscriptions.vehicleAdded().listen((event) {
print('New vehicle: ${event.vehicleAdded.brand} ${event.vehicleAdded.model}');
});
// Cancel when done (e.g. in Flutter widget dispose)
await subscription.cancel();
If you supply a headersProvider on the WebSocket adapter, its result is returned from connectionInitPayload() and sent in the connection_init message — your server can use it to authenticate the WebSocket session without needing HTTP headers on the upgrade request.
Error handling¶
If the server returns a GraphQL error, the generated client throws a GraphLinkException containing the list of errors from the response. Wrap calls in a try/catch:
import 'generated/types/graph_link_error.dart';
try {
final res = await client.queries.getVehicle(id: 'bad-id');
print(res.getVehicle.brand);
} on GraphLinkException catch (e) {
for (final error in e.errors) {
print('GraphQL error: ${error.message}');
if (error.locations != null) {
for (final loc in error.locations!) {
print(' at line ${loc.line}, column ${loc.column}');
}
}
}
} catch (e) {
// Network error, timeout, etc.
print('Request failed: $e');
}
The _all_fields fragment¶
When generateAllFieldsFragments: true is set in the config, GraphLink generates a named fragment for every type in the schema. The autoGenerateQueries: true option uses these fragments to automatically build the query strings for every operation. Instead of writing query strings by hand, GraphLink inlines the fields from the fragment. This means you never have to maintain query strings manually — when you add a field to a type in the schema, the generated client automatically fetches that field.
You can also reference _all_fields_Vehicle by name in any hand-written queries. Use the shorthand ... _all_fields and GraphLink resolves it to the appropriate type-specific fragment based on the field's return type.
type Query {
# GraphLink resolves _all_fields to _all_fields_Vehicle for this field
getVehicle(id: ID!): Vehicle! @glCache(ttl: "2m", tags: ["vehicles"])
}
Barrel file¶
GraphLink emits a single barrel file, graphlink.dart, alongside the generated output. It re-exports every generated enum, input, type, and interface — plus the client files, so withClientFiles output is covered too — from one place:
export 'enums/fuel_type.dart';
export 'inputs/add_vehicle_input.dart';
export 'types/vehicle.dart';
export 'types/get_vehicle_response.dart';
export 'client/graph_link_client.dart';
// ... one export line per generated file
Import it once instead of reaching into individual files under enums/, inputs/, types/, and client/:
import 'package:my_app/generated/graphlink.dart';
final client = GraphLinkClient.withHttp(url: 'http://localhost:8080/graphql');
final res = await client.queries.getVehicle('42'); // Vehicle, AddVehicleInput, etc. all in scope
The barrel file is regenerated on every run alongside everything else — there's nothing to configure.
Operation name in URL¶
Set "operationNameAsParameter": true to include the operation name as a URL query parameter on every request. This is useful for identifying operations in server logs, APM dashboards, and proxies without parsing the request body:
The generated adapter function gains a second parameter and the client appends ?operationName=OperationName to every HTTP request:
// Generated adapter signature with operationNameAsParameter: true
Future<String> Function(String payload, String operationName) adapter
The WebSocket adapter is unaffected — operation names on subscriptions flow through the graphql-ws protocol message payload.
Nullable fields¶
By default, nullable schema fields generate optional constructor parameters (this.fieldName). Set "nullableFieldsRequired": true to force required this.fieldName on every field regardless of nullability:
With the default false, a nullable field like ownerId: ID generates this.ownerId (optional). With true, it generates required this.ownerId — you must always pass it explicitly, even as null.
File uploads¶
When your schema uses the built-in Upload scalar, GraphLink generates a GLUpload type and produces multipart-aware upload logic in the HTTP adapter:
import 'generated/types/gl_upload.dart';
import 'dart:io';
final file = File('/path/to/document.pdf');
final upload = GLUpload(
stream: file.openRead(),
length: await file.length(),
filename: 'document.pdf',
mimeType: 'application/pdf',
);
final result = await client.mutations.uploadDocument(file: upload);
print(result.uploadDocument); // e.g. "https://cdn.example.com/document.pdf"
The Dio and http adapters both handle multipart encoding automatically when a mutation argument contains a GLUpload. No extra configuration is needed.