Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ All notable changes to this project will be documented in this file.

### Fixed

- **Outgoing header names keep the casing you wrote, instead of reaching the wire lowercased.** `DioNetworkDriver` left `preserveHeaderCase` at Dio's default of `false`, so the IO adapter normalised every key on the way out: `User-Agent` went as `user-agent`, `X-Request-Id` as `x-request-id`. HTTP/1.1 says header names are case-insensitive and most servers honour that, but the consumers that read a header by exact key do not, and they fail quietly: ExoPlayer looks its request headers up case-sensitively and simply finds nothing, so a media request loses its user agent and gets served the wrong stream rather than an error anyone can see. Found on a consumer app whose video playback broke only in release, against one CDN. The driver now sets `preserveHeaderCase: true` and passes the caller's key through untouched. Two things are worth knowing before you rely on it. It only holds where the IO adapter runs, so mobile and desktop keep the casing and **web still lowercases**, because `dio_web_adapter` writes headers through `XMLHttpRequest.setRequestHeader` and never reads the flag. And the response side is unchanged: `MagicResponse.headers` keys still arrive lowercase, since `HttpHeaders` lowercases on receipt whatever the peer sent. (`lib/src/network/drivers/dio_network_driver.dart`)

- **`MagicPaginatedListView` wore its loading footer through a refresh.** The footer means "there is more, and it is on its way", and a refresh is the opposite statement: those rows are being replaced rather than added to. It was gated on `isLoading && items.isNotEmpty`, which a refresh over a non-empty list satisfies, so every pull-to-refresh and every filter change grew a footer promising a page that had not been requested. Now gated on `isLoadingMore`. Covered by `a refresh does not wear the loading-more footer`, which holds the window open with a fetcher `Completer` because `Http.fake` answers synchronously and `tester.pump()` drains microtasks before it builds. (`lib/src/ui/magic_paginated_list_view.dart`)

## [0.0.9] - 2026-08-26
Expand Down
24 changes: 24 additions & 0 deletions doc/basics/http-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Magic provides a powerful HTTP client through the `Http` facade, built on top of

- [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)
Expand All @@ -29,6 +32,7 @@ Magic provides a powerful HTTP client through the `Http` facade. Built on top of
<a name="configuration"></a>
## Configuration

<a name="network-config"></a>
### Network Config

Create `lib/config/network.dart`:
Expand All @@ -51,6 +55,7 @@ Map<String, dynamic> get networkConfig => {
};
```

<a name="register-in-config"></a>
### Register in Config

```dart
Expand All @@ -71,6 +76,25 @@ Don't forget to add `NetworkServiceProvider` to your app providers:
],
```

<a name="header-casing"></a>
### 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.

<a name="making-requests"></a>
## Making Requests

Expand Down
6 changes: 6 additions & 0 deletions lib/src/network/drivers/dio_network_driver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ class DioNetworkDriver implements NetworkDriver {
connectTimeout: Duration(milliseconds: timeout),
receiveTimeout: Duration(milliseconds: timeout),
headers: defaultHeaders,
// Dio defaults this to false, so the IO adapter lowercases every
// header key on the wire (e.g. `User-Agent` -> `user-agent`). A
// case-sensitive consumer (ExoPlayer's header lookup) silently gets
// the wrong value instead of an error, so preserve the caller's
// casing rather than let Dio normalise it away.
preserveHeaderCase: true,
),
);
}
Expand Down
4 changes: 2 additions & 2 deletions skills/magic-framework/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
name: magic-framework
description: "Write correct, idiomatic code in a Flutter app that depends on the `magic` framework (Laravel-inspired: IoC container, 18 facades, Eloquent-style ORM, service providers, reactive controllers, GoRouter routing, validation, auth, broadcasting). Use whenever code imports `package:magic/magic.dart` or `package:magic/testing.dart`, or the work touches Magic.init, MagicApp, a facade (Auth/Http/Cache/DB/Echo/Event/Gate/Config/Lang/Launch/Log/Pick/MagicRoute/Schema/Session/Storage/Vault/Crypt), a Model, MagicController, a MagicView, MagicFormData, FormRequest, a ServiceProvider, a migration, or the artisan make:* CLI. UI styling is Wind (separate wind-ui skill). Do NOT use for plain Flutter or Wind-only work with no magic import."
when_to_use: "Use proactively when editing or scaffolding a magic app: Magic.init / a facade / a Model / a MagicController or MagicView / a form (MagicFormData, FormRequest, Validator) / a ServiceProvider / a route or MagicMiddleware / a migration / MagicStateMixin + RxStatus + fetchList / Session flash + old() + trans() / testing with MagicTest + Http.fake/Auth.fake / the artisan make:* CLI / the magic_deeplink, magic_notifications, magic_social_auth, magic_starter, magic_payments, or magic_devtools plugins. Trigger even when the user does not say the word 'magic'. Do NOT trigger for plain Flutter or Wind-only UI with no package:magic import."
version: 0.1.15
version: 0.1.16
---

<!-- magic 0.0.9 | Skill v0.1.15 (2026-09-10). API surface verified against lib/src. -->
<!-- magic 0.0.9 | Skill v0.1.16 (2026-09-10). API surface verified against lib/src. -->

# Magic Framework

Expand Down
2 changes: 2 additions & 0 deletions skills/magic-framework/references/http-network.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ Configuration options:
- `headers`: Default headers sent with every request
- `interceptors`: List of interceptor class names to register on boot

Outgoing header names keep their casing on mobile and desktop (`preserveHeaderCase: true` on the Dio driver), which is what a case-sensitive reader such as ExoPlayer needs. On the web they are still lowercased by the browser, so never build a web feature on a case-sensitive header. Response headers are the other direction: `MagicResponse.headers` keys are always lowercase, so read them with a lowercase key.

## Http Facade

The `Http` facade provides static access to the network driver for making requests.
Expand Down
59 changes: 59 additions & 0 deletions test/network/preserve_header_case_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:magic/magic.dart';

void main() {
group('DioNetworkDriver preserveHeaderCase', () {
test(
'a mixed-case header key reaches the wire with its original casing',
() async {
// 1. Capture the raw request bytes off a loopback socket: HttpHeaders
// lowercases on receipt regardless of what the client sent, so only
// the bytes on the wire can prove the outgoing casing.
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final rawRequest = Completer<String>();
final buffer = StringBuffer();
server.listen((socket) {
socket.listen((data) {
buffer.write(utf8.decode(data));
// 2. Answer once, and only after the head has fully landed. A
// request split across two TCP segments would otherwise write
// to an already closed socket, and that failure would surface
// as a test failure about something other than header casing.
if (rawRequest.isCompleted ||
!buffer.toString().contains('\r\n\r\n')) {
return;
}
rawRequest.complete(buffer.toString());
socket.write(
'HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n',
);
socket.close();
});
});
final driver = DioNetworkDriver(
baseUrl:
'http://${InternetAddress.loopbackIPv4.address}:${server.port}',
);
final requestHeaders = <String, String>{}
..['User-Agent'] = 'Watchools/1.0';
// 3. Fire the request; a malformed/short response is fine, only the
// outgoing bytes captured above matter for this assertion.
unawaited(
driver
.get('/', headers: requestHeaders)
.catchError((_) => MagicResponse(data: null, statusCode: 0)),
);
final requestText = await rawRequest.future.timeout(
const Duration(seconds: 5),
);
await server.close();
expect(requestText, contains('User-Agent: Watchools/1.0'));
expect(requestText, isNot(contains('user-agent:')));
},
);
});
}
Loading