From 52f12457651ddcd0cc7a579cbc08388c381822a5 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Wed, 9 Sep 2026 16:47:01 +0300 Subject: [PATCH 1/3] fix(network): preserve header case in the Dio driver Dio's preserveHeaderCase defaults to false (dio-5.9.2 lib/src/options.dart:151), and the IO adapter forwards that default straight into dart:io's HttpClientRequest.headers.set (io_adapter.dart:109), which lowercases every header key on the wire. A case-sensitive consumer (ExoPlayer's User-Agent lookup) silently receives the wrong value instead of an error. Set preserveHeaderCase: true on the driver's BaseOptions so a caller-supplied key such as User-Agent reaches the socket unchanged. This changes what goes on the wire for every existing consumer of magic's Http facade: any header whose casing previously arrived normalised to lower-case now arrives exactly as the caller wrote it. No consumer should depend on the old behaviour, since HTTP header names are case-insensitive by spec, but a consumer parsing raw header bytes rather than going through a case-insensitive lookup would be affected. Added test/network/preserve_header_case_test.dart, which opens a loopback socket and asserts the raw request bytes carry 'User-Agent:' rather than 'user-agent:'; a HttpHeaders-based assertion cannot prove this because the receiving side's HttpHeaders lowercases on parse regardless of what the client sent. --- .../network/drivers/dio_network_driver.dart | 6 +++ test/network/preserve_header_case_test.dart | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 test/network/preserve_header_case_test.dart diff --git a/lib/src/network/drivers/dio_network_driver.dart b/lib/src/network/drivers/dio_network_driver.dart index f3ea815..e4d3652 100644 --- a/lib/src/network/drivers/dio_network_driver.dart +++ b/lib/src/network/drivers/dio_network_driver.dart @@ -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, ), ); } diff --git a/test/network/preserve_header_case_test.dart b/test/network/preserve_header_case_test.dart new file mode 100644 index 0000000..a9fa26b --- /dev/null +++ b/test/network/preserve_header_case_test.dart @@ -0,0 +1,54 @@ +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(); + final buffer = StringBuffer(); + server.listen((socket) { + socket.listen((data) { + buffer.write(utf8.decode(data)); + if (!rawRequest.isCompleted && + buffer.toString().contains('\r\n\r\n')) { + 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 = {} + ..['User-Agent'] = 'Watchools/1.0'; + // 2. 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:'))); + }, + ); + }); +} From 28ce5233c605e64f028045913bcb74576ae692d4 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Thu, 10 Sep 2026 23:58:10 +0300 Subject: [PATCH 2/3] test(network): answer the socket only once the request head has landed The response write and the close sat in the per-chunk callback with no guard, so a request arriving in two TCP segments would write to an already closed socket and fail the test for a reason unrelated to header casing. Both now sit behind the same completed-head condition as the completer above them. --- test/network/preserve_header_case_test.dart | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/network/preserve_header_case_test.dart b/test/network/preserve_header_case_test.dart index a9fa26b..3f4e575 100644 --- a/test/network/preserve_header_case_test.dart +++ b/test/network/preserve_header_case_test.dart @@ -19,10 +19,15 @@ void main() { server.listen((socket) { socket.listen((data) { buffer.write(utf8.decode(data)); - if (!rawRequest.isCompleted && - buffer.toString().contains('\r\n\r\n')) { - rawRequest.complete(buffer.toString()); + // 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', ); @@ -35,7 +40,7 @@ void main() { ); final requestHeaders = {} ..['User-Agent'] = 'Watchools/1.0'; - // 2. Fire the request; a malformed/short response is fine, only the + // 3. Fire the request; a malformed/short response is fine, only the // outgoing bytes captured above matter for this assertion. unawaited( driver From 3e65da4abd14ce5f3f81ddc0f1e7b1ce9e7896ed Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Thu, 10 Sep 2026 23:58:10 +0300 Subject: [PATCH 3/3] docs(network): record the header-casing fix and its web caveat The driver change alters wire behaviour for every Http consumer, so it needs the CHANGELOG entry the post-change sync requires. The platform split belongs in doc/ rather than only in the PR body: casing is preserved where the IO adapter runs, and the web still lowercases because dio_web_adapter writes through XMLHttpRequest.setRequestHeader. Response headers stay lowercase either way. --- CHANGELOG.md | 2 ++ doc/basics/http-client.md | 24 +++++++++++++++++++ skills/magic-framework/SKILL.md | 4 ++-- .../references/http-network.md | 2 ++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19f7071..192c1f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/doc/basics/http-client.md b/doc/basics/http-client.md index 34e6c2d..0984e91 100644 --- a/doc/basics/http-client.md +++ b/doc/basics/http-client.md @@ -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) @@ -29,6 +32,7 @@ Magic provides a powerful HTTP client through the `Http` facade. Built on top of ## Configuration + ### Network Config Create `lib/config/network.dart`: @@ -51,6 +55,7 @@ Map get networkConfig => { }; ``` + ### Register in Config ```dart @@ -71,6 +76,25 @@ Don't forget to add `NetworkServiceProvider` to your app providers: ], ``` + +### 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 diff --git a/skills/magic-framework/SKILL.md b/skills/magic-framework/SKILL.md index 00ce240..1b8966d 100644 --- a/skills/magic-framework/SKILL.md +++ b/skills/magic-framework/SKILL.md @@ -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 Framework diff --git a/skills/magic-framework/references/http-network.md b/skills/magic-framework/references/http-network.md index 635297e..5f78463 100644 --- a/skills/magic-framework/references/http-network.md +++ b/skills/magic-framework/references/http-network.md @@ -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.