Skip to content

HTTP Client

View raw .md

Magic provides a powerful HTTP client through the Http facade, built on top of Dio, with a clean API for making requests and handling responses.

On this page

Introduction#

Magic provides a powerful HTTP client through the Http facade. Built on top of Dio, it offers a clean, expressive API for making HTTP requests and handling responses, just like you would expect from Laravel.

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,
});

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': '[email protected]',
  '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': '[email protected]',
});

// 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': '[email protected]',
});

// 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.

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.

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.

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);
}

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 MSEmptyState(title: '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.

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

Magic handles Laravel-style 422 validation errors elegantly:

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#

dart
// Pick and upload an image
final image = await Pick.image();

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 Pick.file(extensions: ['pdf', 'doc']);

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

Interceptors#

Create interceptors to modify requests or handle responses globally:

dart
class AuthInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    // Add auth token to every request
    final token = await Auth.getToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    // Log or transform successful responses
    handler.next(response);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    // Handle errors globally
    if (err.response?.statusCode == 401)

Register interceptors in your NetworkServiceProvider:

dart
class NetworkServiceProvider extends ServiceProvider {
  @override
  void boot() {
    Http.addInterceptor(AuthInterceptor());
    Http.addInterceptor(LoggingInterceptor());
  }
}

Configuring the Underlying Driver#

For SDK integrations that need direct Dio access (such as sentry_dio for performance tracing or certificate pinning), resolve the DioNetworkDriver from the IoC container and call configureDriver():

dart
final driver = Magic.make<DioNetworkDriver>('network');
driver.configureDriver((dio) {
  // Attach a Sentry performance tracing interceptor
  dio.addSentry();
});

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,
  );
});

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.

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();
esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.