Flutter client
dartisan client:flutter generates a typed Dart client package from your
routes. The package is plain Dart on package:http, so it works in a
Flutter app (mobile and desktop) and in Dart command-line tools. Because the
server and the app share a language, the client is built from the server's
own route table and validation rules, not from a hand-written spec, and you
don't need build_runner.
dart run bin/dartisan.dart client:flutter --output=../mobile/packages/app_client
| Option | Default | Meaning |
|---|---|---|
--output |
../app_client |
Directory of the package, relative to the app |
--package-name |
app_client |
Package name, also the client class (AppClient) |
--prefix |
/api |
Only routes under this path (/ for all) |
--base-url |
APP_URL |
Default base URL of the client |
--locale |
APP_LOCALE |
Language of the validation messages |
--state |
none | State adapters: riverpod, bloc, cubit (comma-separated) |
--watch |
Regenerate when routes, requests, resources or .env change |
Add it to the Flutter app with a path dependency (app_client: {path: packages/app_client}) and run the command again whenever the routes
change. Generated files are overwritten. pubspec.yaml and
analysis_options.yaml are written only once, so you can add to them.
What comes from where#
The command boots the application like openapi:generate and reads each
route's metadata (Routing).
A new project (dartisan new) already declares it for its auth routes, so
api.auth.register(...), login(...), me() and logout() are typed from
the start:
| On the route | In the client |
|---|---|
.name('users.store') |
api.users.store(...); a one-segment name such as ping is a method on the client itself |
{id} with .whereNumber('id') |
a positional int id (other parameters are String) |
.validates(FormRequest) / .rules({...}) |
named parameters typed from the rules, plus an input class with the rules checked on the device |
.returns(schema, name: 'User') |
a User model with fromJson and toJson; date-time strings are DateTime |
shape: .paginated / .simplePaginated / .cursorPaginated |
Page<User>, SimplePage<User>, CursorPage<User> and a page or cursor parameter |
shape: .collection |
List<User> |
'title': 'User' in a schema |
the model's name: a schema reused inside other responses (the user of a token response) is the same User class |
.returns(const {}, status: 204) |
Future<void> |
file rules |
http.MultipartFile parameters, sent as multipart/form-data |
router.ws(...) |
a method returning the ws:// / wss:// URL |
| no metadata | Future<Object?> with a body or query map |
final api = AppClient(baseUrl: 'https://api.example.com');
final user = await api.users.store(
name: 'Teo',
email: 'teo@example.com',
password: 'Secret123',
passwordConfirmation: 'Secret123',
);
final Page<User> page = await api.users.index(page: 2);
print('${page.items.length} of ${page.total}');
Errors#
Laravel's error bodies become exceptions:
| Status | Exception | Useful fields |
|---|---|---|
| 422 | ValidationException |
errors per field, first('email') |
| 401 | UnauthenticatedException |
|
| 429 | TooManyRequestsException |
retryAfter |
| other 4xx/5xx | ApiException |
status, message, body |
try {
await api.users.store(...);
} on ValidationException catch (e) {
setState(() => emailError = e.first('email'));
}
Validation on the device#
Each input class has the server's rules as static const rules, a
validate() method returning the messages per field, and one
validate<Field> function per field that fits TextFormField.validator:
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
validator: UsersStoreInput.validateEmail,
);
The messages are not translated a second time. They were resolved by the
server's translator when the client was generated, with the form request's
attributes() and messages(), so they read exactly like the 422 the
server would send. The rule semantics are copied from the server too. They
cover empty values, nullable, sometimes and bail, and min/max
choose the string, numeric or array message by the value's type, as on the
server. The generator's test runs both on the same inputs and expects the
same messages.
Rules that need the server, such as unique, exists, dates, files, closures
and wildcard (tags.*) rules, are left out. The input class lists them
("Checked by the server only"), and so does the command's output. The
server stays the authority: a client that passes still gets a 422 when, for
example, the email is taken.
confirmed compares two fields, so a single-field validator skips it unless
you pass the other values: validatePassword(value, {'password_confirmation': confirmController.text}). validate() on the whole input includes it.
Tokens#
Requests send Authorization: Bearer <token> when the client's TokenStore
has one. The default MemoryTokenStore forgets the token when the app
restarts. Implement TokenStore over flutter_secure_storage to keep it:
final class SecureTokens implements TokenStore {
const SecureTokens(this.storage);
final FlutterSecureStorage storage;
@override
Future<String?> read() => storage.read(key: 'token');
@override
Future<void> write(String? token) => token == null
? storage.delete(key: 'token')
: storage.write(key: 'token', value: token);
}
final api = AppClient(tokens: SecureTokens(const FlutterSecureStorage()));
final login = await api.auth.login(email: email, password: password);
await api.tokens.write(login.token);
final me = await api.auth.me(); // sent with the token
Server-sent events and WebSockets#
events(path) reads a Response.sse stream as SseMessages (event,
data, id, json). It ends at the server's </stream> event:
await for (final message in api.events('/api/orders/7/status')) {
print(message.json);
}
A WebSocket route becomes a method that returns its URL. Connect with the
client's headers so the route's auth middleware sees the token:
final socket = await WebSocket.connect(
api.socketRoomsByRoom('lobby').toString(),
headers: await api.requestHeaders(),
);
On Flutter web, browsers can't set WebSocket headers and package:http
buffers streamed responses, so use these two on mobile and desktop.
State management#
--state adds adapters on top of the client. They use the pure-Dart
riverpod and bloc packages, which flutter_riverpod and flutter_bloc
build on, so ProviderScope, BlocProvider and BlocBuilder work with
them as usual. The command adds the dependencies to a new package's
pubspec.yaml. For an existing one, it prints the line to add.
dart run bin/dartisan.dart client:flutter --state=riverpod,cubit
Riverpod (providers.dart). There is one provider for the client and
a FutureProvider.autoDispose for each GET route that returns data. A
route with parameters is a family. With several parameters, the argument
is a record, which compares by value, so each build gets the same cached
request:
ProviderScope(
overrides: [
appClientProvider.overrideWithValue(
AppClient(baseUrl: 'https://api.example.com', tokens: SecureTokens(storage)),
),
],
child: const App(),
);
final user = ref.watch(usersShowProvider(7)); // AsyncValue<User>
final page = ref.watch(usersIndexProvider(2)); // AsyncValue<Page<User>>
ref.invalidate(usersShowProvider); // refetch
Writes have no provider. Call the client from a handler:
ref.read(appClientProvider).auth.login(...).
Cubit (cubits.dart) and Bloc (blocs.dart). There is one per
route: load(...) for a GET and submit(...) for a write, or an event
such as UsersShowRequested(7) for a Bloc. The state is a
RemoteState<T>:
| State | Meaning |
|---|---|
RemoteIdle |
nothing requested yet |
RemoteLoading |
in flight; previous keeps the last data while reloading |
RemoteData |
data |
RemoteError |
error; validation is the ValidationException of a 422 |
BlocProvider(
create: (_) => AuthLoginCubit(api),
child: BlocBuilder<AuthLoginCubit, RemoteState<AuthToken>>(
builder: (context, state) => switch (state) {
RemoteLoading() => const CircularProgressIndicator(),
RemoteError(:final validation) => Text(validation?.first('email') ?? 'Failed'),
_ => LoginForm(onSubmit: (email, password) => context
.read<AuthLoginCubit>()
.submit(email: email, password: password)),
},
),
);
Untyped body/query maps are left out of the adapters, because a new
map on every build would never compare equal. Routes that declare their
input get every field.
Watch mode#
--watch regenerates the package whenever a file under lib, routes,
bootstrap or config changes, or .env does. The route table is compiled
into the running program, so each run is a fresh client:flutter process.
Changes saved during a run start another one. The generated package
itself is ignored when it sits inside the project. Keep it running next
to dartisan serve --watch:
dart run bin/dartisan.dart client:flutter --output=../mobile/packages/app_client --state=riverpod --watch