# 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`](https://doc.saymyframe.com/modules/dio.md) | `appRest` in `lib/core/rest/app_rest.dart`, which sends its requests over a `Dio` |

```bash
smf create my_app -m dio
```

The role reads the address of the API from `.env`, so `dio` brings [`dart_define`](https://doc.saymyframe.com/modules/dart-define.md) 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:

```bash title=".env"
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](https://doc.saymyframe.com/guides/env.md#one-file-for-all-flavors) 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:

```dart
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:

```dart
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:

```dart
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:

```dart
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>),
  ];

  @override
  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](https://doc.saymyframe.com/guides/sign-in.md), 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`, `cookie` and `proxy-authorization` of the caller. Only a hop back to the origin of the app gets the token again.
- A header `authorization` that 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`](https://doc.saymyframe.com/modules/firebase-auth.md) 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](https://doc.saymyframe.com/guides/sign-in.md#a-token-for-your-server).

## 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:

```dart
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](https://doc.saymyframe.com/extending/role-provider.md#rest-api).
