Dart
The Dart SDK is an idiomatic package emitted from the same GlottoIR as every other target.
Operations under a declared resource hang off an accessor — client.pets.listPets() — while an
operation you leave ungrouped stays a method on Client. Request/response shapes are Dart classes
with fromJson/toJson; binary download responses expose byte streams and bounded byte reads.
Manual pages expose typed items, full response metadata and explicit continuation. RequestOptions controls headers, deadlines, retries, extra parameters and cancellation. Binary operations return an owned BinaryDownload with bounded readAll, pipe, byte chunks, response metadata and close. Close the client when finished to release SDK-owned HTTP connections; injected clients remain caller-owned. See pagination, request retries and controls, streaming, and file transfers.
Quickstart
dart pub add petstore
import 'package:petstore/petstore.dart';
final client = Client(token: "<token>");
try {
final result = await client.pets.createPet(
PetCreate(name: "Biscuit", species: PetCreateSpecies.cat),
);
} finally {
client.close();
}
Typed models
Each GlottoIR model becomes a Dart class with fromJson/toJson; operations decode and return
the typed response and accept a typed request body. Discriminated unions resolve to the right
variant.
Typed errors
Operations throw an ApiError carrying the parsed error body, with an ApiErrorKind enum so
callers switch (error.kind) / compare ApiErrorKind.notFound rather than matching status codes.
See Errors.
Pagination
Paginated list methods return a Stream that walks every page as you listen, advancing the cursor
for you. See Pagination.
Retries & backoff
Transient failures (5xx, 429, transport errors) retry with exponential backoff and jitter,
configurable per client. See Retries & timeouts.
SSE streaming
Server-sent-event endpoints return a Stream of typed events decoded from the text/event-stream
framing. See Streaming and Authentication.
Unknown response fields
Your API can add a response field without it being a breaking change — but a generated class has no field to put it in, and its fromJson factory reads only the keys it declares. The generated
models keep it instead: a field the SDK wasn’t generated from is retained on decode, readable
through an accessor, and written back out when the model is re-serialized.
final pet = await client.pets.createPet(body);
// A field your API started returning after this SDK was generated.
final species = pet.extraFields['species'];
// Re-encoding preserves it — a read-modify-write never silently drops it.
final json = jsonEncode(pet.toJson());
extraFields is a getter returning Map<String, dynamic>, so nested objects and lists survive
intact, and retention is recursive.
Your existing construction still compiles: the retention parameter is optional and named.
The retained fields are read-only by design. To send a field your spec doesn’t model yet, use the per-call extra-body escape hatch rather than writing to the retained bag.
Missing and null fields
By default an optional field that decodes from JSON null is null, the same as an omitted key,
and toJson() drops it — so a read-modify-write sends a decoded null back as an absent key. Set
targets.dart.model_presence: explicit to keep missing, null
and a value apart: declared fields become sealed Field<T> states (Field.missing(),
Field.nullValue(), Field.value(value)). That changes constructor arguments and properties; the
generated README shows the idioms.
File uploads
multipart/form-data operations build the multipart body from their fields for you; an
application/octet-stream operation takes a positional List<int> body sent raw with the right
Content-Type (List<int>? body, a named argument, when the body is optional, see below).
Optional request bodies
When your spec marks a request body optional (required: false), the argument is a named
parameter, because a Dart signature cannot mix optional positionals with its named group: Model? body for a named schema ($ref) whose schema does not admit null, Map<String, dynamic>? body for
an inline map and List<int>? body for an octet-stream one. An inline scalar or array body, a union
with no class of its own, or a body whose schema admits null (marked nullable on the $ref or on the schema it names, or a union with a null member) is Object? body, whose default is
a sentinel so that null is still a value you can send. Leave the argument off to send no body and
no Content-Type, or pass a value to send it. A falsy value is still a value: false, 0 and ""
are sent as written, and so is null in an Object? body. (In Model? body or
Map<String, dynamic>? body, null reads as left out.) An optional
multipart/form-data body makes every part a named, optional argument: the parts you pass are the
parts sent, and passing none sends no body. The same holds on paginated operations (which re-send the
body unchanged on every later page) and on streaming operations with a JSON body. A streaming operation
whose body is octet-stream or multipart sends the body or parts you pass exactly as the buffered call
sends them; what leaving such an optional body out sends is not yet specified (tracked in #5747). An operation whose config
sets auto_populate body_fields is the one
exception to “no body”: it sends just those constants (with their Content-Type) when you leave the
body out. A required body stays a positional argument on a buffered, raw-response or streaming
method and on a paginated method’s …Page companion. The auto-paginating iterator takes it as the
named body: instead, defaulting to the same sentinel, so a call that left the body off before the
iterator sent bodies still compiles; there a body whose schema does not admit null has no JSON
null to send, so passing body: null reads as left out (the same request as omitting it), and a
body whose schema admits null sends the JSON null.
Secure token storage
The client takes an optional tokenStore parameter. Supply one and the bearer token is read from it
per request (falling back to the static token); on OAuth2 client-credentials APIs the token cache
is also persisted through it, so a restarted app reuses a still-valid token instead of minting a new
one. See Authentication.
Because Flutter is a mobile platform, the contract is asynchronous — the shape a platform-backed store can actually implement:
abstract class TokenStore {
Future<String?> getToken();
// OAuth2 client-credentials APIs also get:
// Future<void> setToken(String value);
}
That means you can back it with flutter_secure_storage,
which keeps the credential in the iOS Keychain or Android Keystore:
class SecureTokenStore implements TokenStore {
final FlutterSecureStorage storage;
SecureTokenStore(this.storage);
@override
Future<String?> getToken() => storage.read(key: 'api_token');
}
Generating the adapter instead
Rather than writing that yourself, opt in and the SDK ships it:
targets:
dart:
secureStorage: true
Your client then carries a FlutterSecureStorageTokenStore, so wiring the platform store is one
expression:
const storage = FlutterSecureStorage();
final client = Client(tokenStore: FlutterSecureStorageTokenStore(read: storage.read));
Pass key: to choose the storage entry (useful when an API has several bearer-style schemes, each
with its own store). On OAuth2 client-credentials APIs the adapter also takes write: — pass
storage.write — so the token cache is persisted.
No storage backend is bundled and no dependency is added. The adapter takes the store’s methods,
not the store itself — Dart function types are structural, so storage.read fits whether it comes
from flutter_secure_storage, an encrypted store of your own, or a test double. Your pubspec.yaml
is unchanged either way, and leaving the flag off emits exactly the SDK you have today.
Connectivity- and lifecycle-aware retries
Two opt-in seams stop a mobile app burning its retry budget against a radio that is down or while it is backgrounded — the same pair the React Native, Kotlin-Android and Swift SDKs ship. Enable either, both, or neither:
targets:
dart:
connectivity: true # pause retries while the device is offline
lifecycle: true # pause backoff while the app is backgrounded
A default Dart SDK is unchanged. With the flags on, the client takes two more optional parameters and consults them before every attempt — a known-offline state waits and re-checks rather than spending the request, and a backgrounded app pauses backoff without consuming a retry attempt:
abstract class ConnectivityMonitor {
Future<bool> isOnline();
}
abstract class AppLifecycle {
Future<bool> isForeground();
}
Both reads are asynchronous, because the Flutter APIs behind them are — connectivity_plus exposes
Future<List<ConnectivityResult>> checkConnectivity(). A synchronous contract would be
unimplementable by the standard package, the same way a synchronous TokenStore was for
flutter_secure_storage.
No package is bundled and nothing imports Flutter, so the SDK stays a plain Dart package. Adapters take a closure, so wiring a real app is one expression per seam:
import 'package:connectivity_plus/connectivity_plus.dart';
final lifecycle = MutableAppLifecycle();
final client = Client(
'https://api.example.com',
connectivity: CallbackConnectivityMonitor(() async =>
(await Connectivity().checkConnectivity())
.any((r) => r != ConnectivityResult.none)),
lifecycle: lifecycle,
);
Flutter reports lifecycle by callback rather than by query, so MutableAppLifecycle is the shape a
WidgetsBindingObserver pushes into:
class _AppState extends State<App> with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
lifecycle.setForeground(state == AppLifecycleState.resumed);
}
}
There is also a CallbackAppLifecycle if you have a lifecycle source you can poll instead.