# HTTP Client

The `Http` facade is Magic's HTTP client, built on top of Dio, with one API for making requests and handling responses.

- [Introduction](#introduction)
- [Configuration](#configuration)
    - [Network Config](#network-config)
    - [Register in Config](#register-in-config)
    - [Header Casing](#header-casing)
- [Making Requests](#making-requests)
    - [GET Requests](#get-requests)
    - [POST Requests](#post-requests)
    - [PUT & DELETE](#put--delete)
- [RESTful Resources](#restful-resources)
- [Paginated Collections](#paginated-collections)
    - [A Source That Is Not a URL](#a-source-that-is-not-a-url)
    - [Cursor or Offset](#cursor-or-offset)
    - [Rendering It Lazily](#rendering-it-lazily)
- [Handling Responses](#handling-responses)
    - [Response Properties](#response-properties)
    - [Validation Errors](#validation-errors)
- [File Uploads](#file-uploads)
- [Interceptors](#interceptors)
    - [Configuring the Underlying Driver](#configuring-the-underlying-driver)
- [Testing HTTP](#testing-http)
- [Gotchas](#gotchas)
- [Related Documentation](#related-documentation)

## Introduction

Every request method on the `Http` facade returns a `MagicResponse` and never throws: a failed request is a value you read, including a transport failure, which arrives as status code `0`. The one exception is `Http.upload()`, which reads the files before it sends anything: a path in `files` that cannot be read throws a `FileSystemException`, and an unsupported file value throws an `ArgumentError` (see [File Uploads](#file-uploads)).

```dart
final response = await Http.get('/users');

if (response.successful) {
  final users = response.data;
}
```

## Configuration

### Network Config

Create `lib/config/network.dart`:

```dart
Map<String, dynamic> get networkConfig => {
  'network': {
    'default': 'api',
    'drivers': {
      'api': {
        'base_url': env('API_BASE_URL', 'https://api.example.com/v1'),
        'timeout': 10000,
        'headers': {
          'Accept': 'application/json',
          'Content-Type': 'application/json',
        },
      },
    },
  },
};
```

#### The default User-Agent

`NetworkServiceProvider` adds a `User-Agent` of `<App Name> (Flutter; <platform>)`
unless `headers` already carries one, reading the name from `app.name` and the
platform from `defaultTargetPlatform`. Without it Dart sends
`Dart/<sdk> (dart:io)`, which says nothing about the app and is identical across
every Flutter client a backend has, so anything the server derives from the agent
answers wrongly rather than partially.

It is skipped on WEB: `User-Agent` is a forbidden header name for
`XMLHttpRequest`, so the browser drops it and sends its own, which is the right
agent there anyway. Set your own in `headers` to override it; the match is
case-insensitive, so writing `user-agent` replaces it rather than adding a second.

**The name is folded to ASCII on the way out, so what you see on the wire may
not be what you wrote.** `dart:io` refuses any header value carrying a byte
above 127 and throws a `FormatException` from `HttpHeaders.set`, which Dio
surfaces as a `DioException`, so an unfolded name would lose every request the
app makes. Accented Latin letters fold to their base letter rather than being
dropped, since dropping leaves a mangled word:

| `app.name` | sent as |
|:---|:---|
| `Uptizm` | `Uptizm (Flutter; iOS)` |
| `Şirket Takip` | `Sirket Takip (Flutter; iOS)` |
| `Café Münster` | `Cafe Munster (Flutter; iOS)` |
| `日本` | `Magic App (Flutter; iOS)` |

The table covers every letter in Latin-1 Supplement and Latin Extended-A, which
is Turkish, German, French, Spanish, Nordic, Polish, Czech and Dutch. A test
walks both ranges and fails on any letter that does not survive, so the claim
checks itself rather than being maintained by hand. A script with no Latin base has nothing to
fold to, so it is dropped and the agent falls back to the config default rather
than opening with a bare space. Set your own `User-Agent` in `headers` if you
need an exact string; it is passed through untouched.

### Register in Config

```dart
await Magic.init(
  configFactories: [
    () => appConfig,
    () => networkConfig,
  ],
);
```

Don't forget to add `NetworkServiceProvider` to your app providers:

```dart
'providers': [
  (app) => NetworkServiceProvider(app),
  // ...
],
```

### Header Casing

Header names you set, in the config map above or per request, reach the wire exactly as you wrote them. `User-Agent` goes out as `User-Agent`, not as `user-agent`.

HTTP/1.1 treats header names as case-insensitive, so this rarely matters. It matters when the other end reads a header by exact key: ExoPlayer looks its request headers up case-sensitively and finds nothing when the key arrives lowercased, which costs you a user agent on a media request and gets the wrong stream served back without an error.

```dart
final response = await Http.get('/stream', headers: {
  'User-Agent': 'MyApp/1.0',
  'X-Request-Id': requestId,
});
```

> [!NOTE]
> This holds on mobile and desktop, where Dio's IO adapter runs. On the web, header names are still lowercased: `dio_web_adapter` writes them through `XMLHttpRequest.setRequestHeader`, which the browser normalises on its own. Do not build a web feature on a case-sensitive header.

Responses are a separate matter. `MagicResponse.headers` keys are always lowercase, whatever casing the server sent, because `HttpHeaders` lowercases on receipt. Read them with a lowercase key.

## Making Requests

### GET Requests

```dart
// Simple GET
final response = await Http.get('/users');

// With query parameters
final response = await Http.get('/users', query: {
  'page': 1,
  'per_page': 25,
  'sort': 'name',
});

// Access the data
if (response.successful) {
  final users = response.data; // Parsed JSON
}
```

### POST Requests

```dart
final response = await Http.post('/users', data: {
  'name': 'John Doe',
  'email': 'john@example.com',
  'password': 'secret123',
});

if (response.successful) {
  final user = response.data;
  Magic.success('Success', 'User created!');
}
```

### PUT & DELETE

```dart
// PUT - Full update
await Http.put('/users/1', data: {
  'name': 'Jane Doe',
  'email': 'jane@example.com',
});

// DELETE
await Http.delete('/users/1');
```

## RESTful Resources

For RESTful APIs, Magic provides resource helper methods:

```dart
// GET /users
final all = await Http.index('users');

// GET /users/1
final one = await Http.show('users', '1');

// POST /users
final created = await Http.store('users', {
  'name': 'New User',
  'email': 'new@example.com',
});

// PUT /users/1
final updated = await Http.update('users', '1', {
  'name': 'Updated Name',
});

// DELETE /users/1
await Http.destroy('users', '1');
```

## Paginated Collections

`Http.index()` and `fetchList()` read a collection in one request and hand you every row. That is the right shape for a settings screen and the wrong one for a log, a check history, or a feed: a long collection rendered as a column of every row costs one build, one layout and one semantics node per row on the first frame, whether or not the reader ever scrolls that far.

`MagicPaginator` reads such a collection one page at a time. It holds the rows fetched so far, knows whether the server has more, and appends rather than replaces:

```dart
final checks = MagicPaginator<CheckRow>(
  url: 'monitors/$id/checks',
  fromMap: CheckRow.fromMap,
  perPage: 50,
);

await checks.loadFirst();   // first page
await checks.loadMore();    // append the next one
await checks.refresh();     // start over from page one

checks.items;         // every row so far, oldest page first
checks.total;         // how many rows MATCH on the server, or null
checks.hasMore;       // is there another page
checks.loadedPages;   // how many pages are held
checks.isLoading;     // a request is in flight
checks.isRefreshing;  // ...rebuilding page one under rows already on screen
checks.isLoadingMore; // ...fetching the page after the last one
checks.error;         // the last failure, cleared by the next success
checks.isEmpty;       // a first page arrived and held nothing
checks.mode;          // cursor, offset or single, read from the response
checks.generation;    // bumps on every landed reset
```

### Three Loading States, Not One

A list renders a loading state three different ways and `isLoading` cannot tell them apart on its own:

| State | What the screen does |
|:---|:---|
| first load, nothing on screen | render a skeleton |
| `isRefreshing`, rows already held | keep the rows, say more is coming |
| `isLoadingMore` | put a footer under the last row |

Blanking a list the reader is already looking at on every filter change is a flash for no information: the rows are still true until the answer lands. And a footer during a refresh promises a page nothing asked for. `MagicPaginatedListView` reads `isLoadingMore` for exactly this reason.

Both are false on a first load, since there is nothing on screen to preserve and no page being appended, and all three are false once the request lands.

> [!NOTE]
> A `refresh()` arriving while a `loadMore()` is in flight is deferred rather than started, so until that page lands the paginator still reports `isLoadingMore` and not `isRefreshing`: a footer stays up through a refresh that has been asked for and not yet begun. That is what is happening on the wire, and it is the only path where the flags follow the REQUEST rather than the caller's most recent ask. A screen that must show its refresh indicator immediately owns that state itself.

### The Total

`total` reads `meta.total`, so it is the size of the whole collection rather than of the pages in hand: `items.length` answers "how much have I fetched", and a header reading "11 of 240" needs the other number.

> [!NOTE]
> It is null on a cursor collection. Laravel's `cursorPaginate()` deliberately does not count, and null is the honest answer there rather than a total invented from the page that happens to be loaded. A later page that omits the key leaves the last known value alone, so an endpoint that sends the count on page one only keeps it.

### The Page Count

`loadedPages` counts what is HELD rather than requests made: a `refresh()` puts it back to one and a failed page counts nothing. A screen that writes its position into a URL wants this rather than the cursor, because a cursor names a position in one ordered result: shared, it drops the reader into the middle of a list with nothing above it, and points nowhere once that row is renamed or deleted. A page count re-fetches pages one to N, which is the same rows with the top intact.

`generation` exists for views that throttle themselves. A list that stops asking once a page adds no rows has to tell that from a fresh first page of the same length, and a row count alone cannot; compare the generation beside it and a `refresh()` re-arms whatever the count had disarmed.

It is a `ChangeNotifier`, so a widget can listen to it directly and a controller can hold several without inventing a state enum per list.

> [!NOTE]
> `loadMore()` is a no-op while a request is in flight and when there is nothing more to fetch, so it is safe to call from a scroll callback that fires every frame.

A failed `loadMore()` keeps the rows already on screen and leaves `hasMore` alone, so the reader does not lose page one because page two timed out, and a retry still has a target. A **transport** failure counts as a failure here: a timeout or a dead link arrives as statusCode 0, and `error` is set rather than the collection reporting itself empty, because "no rows" and "nobody answered" are different screens.

`refresh()` issued while the tail is auto-fetching waits for that page to land and then starts over, so a pull-to-refresh cannot retract over stale rows having done nothing. Disposing the paginator while a request is in flight is safe.

### A Source That Is Not a URL

Some collections do not arrive from `Http.get`. A billing history reaches the client through a payments service whose store build refuses rather than answering; a search result needs its query assembled; a list comes from a local store. Pointing the url constructor at the endpoint such a service wraps walks around the abstraction, and the abstraction is usually there for a reason.

`MagicPaginator.fetcher` pages anything:

```dart
final invoices = MagicPaginator<Invoice>.fetcher(
  fetch: (MagicPageRequest request) async {
    final page = await Payments.getInvoices(cursor: request.cursor);

    return MagicPage<Invoice>(
      items: page.invoices,
      nextCursor: page.nextCursor,
    );
  },
);
```

`fetch` receives a `MagicPageRequest`: the `cursor` the previous page reported, plus `isFirst`. Report `nextCursor` when the source pages by token, or `hasMore` when it pages by something the paginator never sees:

```dart
fetch: (MagicPageRequest request) async {
  final int page = request.isFirst ? 1 : _page + 1;
  ...

  return MagicPage<Row>(items: rows, hasMore: page < lastPage);
}
```

> [!WARNING]
> A source that keeps its own position MUST branch on `isFirst`. The cursor is null for every page of such a source, so without it a `refresh()` is indistinguishable from a `loadMore()` and renders whatever page it was up to as the whole list.

> [!NOTE]
> A fetcher reports failure by **throwing**, where an endpoint reports it with a status code. Either way the rows already in hand stay, `error` is set, and `hasMore` is untouched so a retry has a target. Only an `Exception` becomes state: an `Error` (a bad cast, a failed assertion) is the fetcher itself being wrong and propagates instead of landing in `error`.

`mode` reports `PaginationMode.fetcher` on this path, because how the pages are addressed is the fetcher's business and the paginator does not know.

Everything else (accumulation, the in-flight and disposal guards, the lazy list) is the same as the url mode.

### Cursor or Offset

The mode is read from the response rather than configured, so one paginator serves whichever the endpoint uses:

| Response `meta` | Mode | Next page |
|:---|:---|:---|
| `next_cursor` | `PaginationMode.cursor` | `?cursor=<token>` |
| `current_page` + `last_page` | `PaginationMode.offset` | `?page=<n+1>` |
| neither | `PaginationMode.single` | there is no next page |

**Prefer `cursorPaginate()` on the server for anything that grows at the head**, which is most live data: checks, events, messages, notifications.

```php
// Drifts: a row inserted at the top between two requests shifts
// everything down, so page two repeats the last row of page one.
return CheckResource::collection($query->paginate($perPage));

// Stable: the cursor names a position in the ordering.
return CheckResource::collection($query->cursorPaginate($perPage));
```

Cursor pagination also costs the database the same at any depth, because it seeks to a position instead of counting past every row it skips. What you give up is a total count and the ability to jump to page five, neither of which an infinitely scrolling list uses.

### Rendering It Lazily

`MagicPaginatedListView` builds the rows the viewport can show and asks for the next page as the tail comes into view:

```dart
WDiv(
  className: 'h-[600px]',
  child: MagicPaginatedListView<CheckRow>(
    paginator: controller.checks,
    itemBuilder: (_, CheckRow row, _) => CheckHistoryRow(row: row),
    separatorBuilder: (_, _) => const WDiv(className: 'h-px bg-gray-200'),
    emptyState: const WText('No checks yet'),
    loadingFooter: const WDiv(className: 'p-4', child: WText('Loading...')),
  ),
)
```

A first page too short to fill the viewport still fetches its successor: the widget checks after the frame whether there is anything to scroll, so a small `perPage` cannot strand the reader on a truncated list. That fill stops on an error and on a page that added no rows, so a failing endpoint is asked once rather than once per frame.

> [!WARNING]
> It is a `ListView`, so it needs a **bounded height**. Dropping it into a page that already scrolls without a bound throws, and reaching for `shrinkWrap: true` to make that work defeats the whole thing: shrink-wrapping measures every row, so all of them get built and nothing is saved. Give it a height, or give the page a sliver-based scaffold.

## Handling Responses

The `MagicResponse` object provides helpful properties and methods for handling API responses.

### Response Properties

```dart
final response = await Http.get('/users');

// Status checks
response.successful        // true if 2xx status
response.failed           // true if 4xx or 5xx, or status 0 (no connection, timeout)
response.unauthorized     // true if 401
response.forbidden        // true if 403
response.notFound         // true if 404
response.isValidationError // true if 422

// Access data
response.statusCode       // HTTP status code
response.data            // Parsed response body
response['key']          // Direct access to data key
response.dataAs<List>()  // Typed access
```

### Validation Errors

A Laravel-style 422 response is parsed into field errors:

```dart
final response = await Http.post('/register', data: formData);

if (response.isValidationError) {
  // Get all errors as a Map
  final errors = response.errors;
  // {'email': ['Email already taken'], 'password': ['Too short']}
  
  // Get flat list of all error messages
  final allMessages = response.errorsList;
  // ['Email already taken', 'Too short']
  
  // Get just the first error (useful for snackbars)
  final firstError = response.firstError;
  // 'Email already taken'
  
  // Get the main error message
  final message = response.errorMessage;
  // 'The given data was invalid.'
}
```

### Controller Integration

Use `ValidatesRequests` mixin in your controller for automatic error handling:

```dart
class AuthController extends MagicController with ValidatesRequests {
  Future<void> register(Map<String, dynamic> data) async {
    clearErrors();
    
    final response = await Http.post('/register', data: data);
    
    if (response.successful) {
      // Handle success
    } else {
      // Automatically populates controller errors from 422 response
      handleApiError(response, fallback: 'Registration failed');
    }
  }
}
```

## File Uploads

> [!NOTE]
> Picking a file from the device (the `Pick` facade) belongs to the `magic_picker` plugin: `flutter pub add magic_picker`, then `dart run magic:dowel plugin:install magic_picker`. See its [Pick page](https://anilcancakir.com/magic/docs/picker/basics/pick).

### Using MagicFile (Recommended)

```dart
// Upload a stored file
final image = await Storage.getFile('avatars/1.jpg');

if (image != null) {
  final response = await image.upload('/upload', fieldName: 'avatar');
  
  if (response.successful) {
    final url = response['url'];
  }
}

// With additional form data
final response = await image.upload(
  '/upload',
  fieldName: 'photo',
  data: {'title': 'Profile Photo', 'public': true},
);
```

### Using Http.upload()

```dart
final file = await Storage.getFile('documents/contract.pdf');

final response = await Http.upload(
  '/documents',
  data: {'title': 'My Document'},
  files: {'document': file},
);
```

The Dio driver catches only `DioException`, so a string path in `files` that cannot be read raises a `FileSystemException` out of the call instead of a status `0` response. A value that is not a path, a `MagicFile`, a `MultipartFile`, a `Uint8List` or a `List<int>` raises an `ArgumentError`. Wrap an upload that takes user-chosen paths in `try`/`catch`.

## Interceptors

Extend `MagicNetworkInterceptor` to modify requests or handle responses and errors globally. Each hook receives a driver-agnostic type (`MagicRequest`, `MagicResponse`, `MagicError`) and returns it, possibly changed:

```dart
class RequestHeaderInterceptor extends MagicNetworkInterceptor {
  @override
  dynamic onRequest(MagicRequest request) {
    request.headers['X-Client'] = 'my-app';
    return request;
  }

  @override
  dynamic onResponse(MagicResponse response) {
    Log.debug('Response ${response.statusCode}');
    return response;
  }

  @override
  dynamic onError(MagicError error) {
    if (error.isUnauthorized) {
      // React to a 401 here
    }
    return error;
  }
}
```

Register an interceptor on the network driver from a service provider's `boot()`:

```dart
class HttpServiceProvider extends ServiceProvider {
  HttpServiceProvider(super.app);

  @override
  Future<void> boot() async {
    Magic.make<NetworkDriver>('network').addInterceptor(RequestHeaderInterceptor());
  }
}
```

`AuthServiceProvider` already registers an `AuthInterceptor` that attaches the guard's token to each request and refreshes it on a 401, so you do not write one for that. To work with Dio's own `Interceptor` type, use `configureDriver` below.

### Configuring the Underlying Driver

For integrations that need direct Dio access (a Dio package that attaches its own interceptor, or certificate pinning), resolve the `DioNetworkDriver` from the IoC container and call `configureDriver()`:

```dart
final driver = Magic.make<DioNetworkDriver>('network');
driver.configureDriver((dio) {
  dio.interceptors.add(LogInterceptor());
});
```

Reporting failed requests to Sentry is the job of the `magic_sentry` plugin (`flutter pub add magic_sentry`, then `dart run magic:dowel plugin:install magic_sentry`), which registers its own interceptor; see its [HTTP Reporting page](https://anilcancakir.com/magic/docs/sentry/basics/http-reporting).

You can also use this hook to pin a certificate by supplying a custom `HttpClientAdapter`:

```dart
final driver = Magic.make<DioNetworkDriver>('network');
driver.configureDriver((dio) {
  dio.httpClientAdapter = PinnedHttpClientAdapter(
    trustedCertificate: certBytes,
  );
});
```

> [!NOTE]
> `configureDriver()` is specific to `DioNetworkDriver`. Call it after `Magic.init()` completes, typically in a service provider's `boot()` method.

## Testing HTTP

Magic provides first-class HTTP faking so tests never make real network calls. For a full guide to request assertions and stubbing strategies, see [HTTP Tests](https://anilcancakir.com/magic/docs/testing/http-tests.md).

```dart
import 'package:magic/testing.dart';

// Replace the real driver with a fake that returns 200 for all requests
Http.fake();

// Stub specific URL patterns
Http.fake({
  '/users': Http.response({'data': []}, 200),
  '/users/1': Http.response({'id': 1, 'name': 'Alice'}, 200),
});

// Restore the real driver when done
Http.unfake();
```

## Gotchas

| Mistake | Fix |
|:---|:---|
| Wrapping `Http` calls in `try`/`catch` for a failed request | The request methods never throw: read `response.successful`. A timeout or dead link is a `MagicResponse` with status code `0`, not an exception |
| Passing a file path to `Http.upload()` without a `try`/`catch` | The files are read before the request, outside the driver's `DioException` handling, so an unreadable path throws a `FileSystemException`. Catch it around `upload()` |
| Looking for `Http.addInterceptor` or passing a Dio `Interceptor` to the driver | Extend `MagicNetworkInterceptor` and call `addInterceptor` on `Magic.make<NetworkDriver>('network')`; reach Dio's own types through `DioNetworkDriver.configureDriver` |
| Relying on header name casing on the web | Casing is preserved on mobile and desktop only; the browser lowercases names, so do not build a web feature on a case-sensitive header |
| Reading a response header with its original casing | `MagicResponse.headers` keys are always lowercase |
| A fetcher-backed `MagicPaginator` that keeps its own page counter | Branch on `request.isFirst`: the cursor is null for every page of such a source, so a `refresh()` is otherwise indistinguishable from a `loadMore()` |
| `MagicPaginatedListView` inside an unbounded-height parent | Give it a bounded height; `shrinkWrap` builds every row and defeats the lazy list |

## Related Documentation

- [HTTP Tests](https://anilcancakir.com/magic/docs/testing/http-tests.md)
- [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md)
- [Forms](https://anilcancakir.com/magic/docs/basics/forms.md)
- [Authentication](https://anilcancakir.com/magic/docs/security/authentication.md)
- [Configuration](https://anilcancakir.com/magic/docs/getting-started/configuration.md)
