REST API
The REST role gives an app one client, appRest, for its requests to a REST API. The role is defined by what the client guarantees, and the guarantees live in code that the role generates, so they hold whichever module sends the requests.
| Part | Role | Module | What it brings |
|---|---|---|---|
| The client | REST API | dio | appRest in lib/core/rest/app_rest.dart, which sends its requests over a Dio |
smf create my_app -m dio
The role reads the address of the API from .env, so dio brings dart_define while that module is the only provider of the values in .env. No screen of the app makes a request on its own: the client is there for the code that you add. An app also gets two test files, test/core/rest/rest_test_server.dart and test/core/rest/app_rest_test.dart, and the permission android.permission.INTERNET.
The address
REST_BASE_URL in .env is the address of the API. Fill it in, then start flutter run again, since a command reads .env when it starts:
REST_BASE_URL=https://api.example.com/v1
While it is empty, the app still builds and starts, and a relative path fails with notConfigured, whose hint names the key.
On a device, http://10.0.2.2:8080 is your computer on the Android emulator, and http://127.0.0.1:8080 on the iOS simulator. A phone reaches it at its address on your network. Use https in a release. The client also sends the token of the user over http://, so an address in a release is yours to get right.
.env is one file, not tied to a flavor. Values in .env tells how each environment gets its own address.
Make a request
appRest has get, head, post, put, patch and delete. A path joins the address, with or without a leading slash, and keeps the path of the address:
final response = await appRest.get('notes', query: {'page': '1'});
| Argument | What it does |
|---|---|
query | The query of the address, as a map |
headers | Headers of this call |
json | For post, put, patch and delete: a value that the client sends as JSON |
timeout | The time of one attempt of this call, 30 seconds by default |
cancel | A RestCancel that ends this call |
A full address with http:// or https:// goes where it says, such as a link to the next page or an upload address. A path that climbs out of the address with .., written as it is or encoded, fails with rejected before anything is sent. So encode what a user types with Uri.encodeComponent.
Call the client from the state of a screen or from a repository, never from a widget. The code of the app creates no other client, and it calls neither createRestTransport() nor appRest.use(), which is for tests.
Read the answer
A RestResponse has status, headers, bytes and text. decoded turns the JSON of the body into your type:
final note = response.decoded(Note.fromJson);
The JSON of a list is a List<dynamic>, so a function of a list takes that and casts its items inside:
final notes = response.decoded(
(List<dynamic> json) => [
for (final item in json) Note.fromJson(item as Map<String, dynamic>),
],
);
A body that is no JSON, a body of another type than the function takes, and any error of the function fail with invalidResponse. The role generates no models. For generated ones, run flutter pub add json_annotation dev:json_serializable dev:build_runner and pass the fromJson of the model.
The headers have lower-case names and join several values of one header with , , so several set-cookie values cannot be told apart there. A body in a coding that the provider did not decode has content-encoding and content-length, and comes as it is. A body that it decoded has neither.
Failures
A call fails only with a RestFailure. Show a text of the app for its reason, and its developerHint in debug mode only. The hint never has a token, a query or a body.
| Reason | When |
|---|---|
notConfigured | A relative path, and REST_BASE_URL is empty or no address of a server |
network | The request did not reach a server, or the connection broke |
timeout | No complete answer within the timeout of the attempt, or no token of the user within tokenWait, 10 seconds |
cancelled | A RestCancel ended the call |
unauthorized | A 401, after one refresh of the token where the client added one |
forbidden, notFound, conflict | A 403, a 404, a 409 |
rejected | Any other 4xx, or a relative path that climbs out of the address |
tooManyRequests | A 429 |
server | A 5xx |
invalidResponse | A body that decoded does not take |
unknown | Anything else, such as a redirect of a POST, with the type of the error as its hint |
failure.status has the status of the answer, failure.response the refusal itself, whose body may say why, and failure.retryAfter the wait that the server named and the client did not take.
Repeats and redirects
GET and HEAD repeat twice at most after a connection that failed and after 429, 502, 503 and 504. They keep to a Retry-After of up to 30 seconds. They also follow at most 5 redirects.
Other methods never repeat, since the server may have done what they asked, and a redirect of another method fails with unknown. So never wrap a call in a loop of your own.
The timeout is per attempt, so a GET with its repeats may take longer than the timeout. A debug build prints one line for each attempt, with the origin and the path alone.
Cancel a call
A RestCancel ends a call and its waits at once, and the call fails with cancelled. Give each call a cancel of its own, and cancel those on their way where the state of a screen is disposed of. This shape comes from a small consumer that SMF's tests keep, a list of notes on a server:
final class Notes extends ValueNotifier<NotesView> {
Notes() : super(const NotesLoading());
final Set<RestCancel> _calls = {};
Future<void> load() async {
final cancel = RestCancel();
_calls.add(cancel);
value = const NotesLoading();
try {
final response = await appRest.get('notes', cancel: cancel);
final notes = response.decoded(_notesOf);
if (!cancel.isCancelled) value = NotesShown(notes);
} on RestFailure catch (failure) {
if (!cancel.isCancelled) value = NotesFailed(failure.reason);
} finally {
_calls.remove(cancel);
}
}
static List<Note> _notesOf(List<dynamic> json) => [
for (final item in json) Note.fromJson(item as Map<String, dynamic>),
];
void dispose() {
for (final call in _calls) {
call.cancel();
}
super.dispose();
}
}
The state knows the role and no module behind it, with one import and no container or router. The widget reads Notes and shows a text for NotesFailed.reason.
The token of the user
In an app with sign-in, each request to the origin of REST_BASE_URL carries the token of appSession as Authorization: Bearer <token>. The origin is the same scheme, host and port. No other request has the token.
- A redirect to another origin takes the token away, with the
authorization,cookieandproxy-authorizationof the caller. Only a hop back to the origin of the app gets the token again. - A header
authorizationthat a call gives stays in place of the token. - On a 401 of a request with that token, the client gets a new token once and repeats once, for every method. Calls at the same time share one refresh.
- A second 401, a refresh that finds nobody, and a user other than the one who made the request all fail with
unauthorized. That signs nobody out, since a server may refuse for other reasons, so the app decides what it means. - A request made while the app starts, before the session is known, waits for the start at most 3 seconds and goes without a token if the session is still not there. So make a request that needs the user after
bootstrap().
The server checks the token itself. With firebase_auth it is a Firebase ID token, and a server verifies it with the Firebase Admin SDK of the project of the flavor. The session also gives the token to other code, with appSession.userToken() and appSession.userTokenAfter(rejected); see Sign-in.
In tests
flutter test answers 400 to every HttpClient, which reaches your code as rejected. RestTestServer of test/core/rest/rest_test_server.dart is a local server that takes that stand-in away while it is open:
late RestTestServer server;
late AppRestClient client;
setUp(() async {
server = await RestTestServer.start();
client = AppRestClient(
transport: createRestTransport(),
baseAddress: '${server.address}/v1',
);
});
tearDown(() => server.close());
test('a relative path reaches the address', () async {
server.answerJson('GET', '/v1/notes', ['a', 'b']);
final notes = await client.get('notes', query: {'page': '1'});
expect(notes.decoded((List<Object?> json) => json.length), 2);
expect(server.requests.single.uri.toString(), '/v1/notes?page=1');
});
Each server takes a free port, so give the client server.address. To test code that calls appRest itself, point it at the server with appRest.use(baseAddress: server.address), or script the answers with appRest.use(transport: …) and a RestTransport of the test.
Inside testWidgets the time is fake, and a real socket never completes there. Make such a request in tester.runAsync, and let it end before the test does, since a request that is still running when the body ends fails the test with a pending timer. A plain test() runs in real time.
For a provider
A provider implements RestTransport of lib/core/rest/rest_transport.dart, one exchange on the wire. See Provide a role.