request method
headers Accepts either a Map<String, List
query Accepts either a Map<String, List
Implementation
Future<HttpResponse> request({
required String method,
required String path,
Map<String, dynamic>? headers,
Object? body,
Map<String, dynamic> query = const {},
}) async {
assert(path.isNotEmpty, 'Path cannot be empty');
String formQuery() {
if (query.isEmpty) {
return '';
}
final pairs = <String>[];
void write(String key, dynamic value) {
if (value == null) return;
if (value is List) {
for (final e in value) {
write(key, e);
}
return;
}
// Percent encoded because the far side decodes: the router reads
// `Uri.queryParametersAll`, so anything written raw here is read as
// syntax there. A `#` in a value truncates the request at the client
// and surfaces as a deserialization error on the server, which is a
// long way from the call site that supplied the value.
//
// jsonEncode is allowed to throw: a value it cannot represent is a
// mistake at the call site, and its `toString()` would be sent as if
// it were the value.
final encoded = switch (value) {
String() => value,
_ => jsonEncode(value),
};
pairs.add(
'${Uri.encodeQueryComponent(key)}'
'=${Uri.encodeQueryComponent(encoded)}',
);
}
query.forEach(write);
final buffer = pairs.join('&');
if (buffer.isEmpty) {
return '';
}
return '?$buffer';
}
final fullPath = switch ((path[0], baseUrl)) {
('/', final String base) => '$base$path${formQuery()}',
('/', null) => throw Exception('Base URL not set'),
_ => '$path${formQuery()}',
};
final uri = Uri.parse(fullPath);
final request = HttpRequest(method: method, url: uri);
if (headers != null) {
void addHeader(String key, dynamic value) {
if (value == null) return;
if (value is List) {
request.headers[key] = value.map((e) => '$e').join(',');
} else {
request.headers[key] = value.toString();
}
}
headers.forEach(addHeader);
}
switch (body) {
case List<int>():
request.bodyBytes = body;
request.headers['content-type'] = 'application/octet-stream';
case List<dynamic>():
case Map<dynamic, dynamic>():
request.body = jsonEncode(body);
request.headers['content-type'] = 'application/json';
case String():
request.body = body;
request.headers['content-type'] = 'text/plain';
// Sent incrementally, so a large upload never has to fit in memory.
// The server side of this is `@Body() Stream<List<int>>`, which reads
// the payload as it arrives.
case Stream<List<int>>():
request.bodyStream = body;
request.headers['content-type'] = 'application/octet-stream';
case Stream<String>():
request.bodyStream = body.map(utf8.encode);
request.headers['content-type'] = 'text/plain';
case Stream<dynamic>():
// Anything else would need a framing format both ends agree on
// (NDJSON, length-prefixing), and the server has no binding for one.
// Better to say so than to invent a format silently.
throw ArgumentError(
'Cannot send a ${body.runtimeType} as a request body. Streamed '
'bodies must be Stream<List<int>> or Stream<String>; map the '
'stream to one of those before sending it.',
);
case null:
break;
default:
request.body = jsonEncode(body);
request.headers['content-type'] = 'application/json';
}
final response = await _send(request);
final hasException = switch (response.statusCode) {
>= 200 && < 300 => false,
_ => true,
};
if (hasException) {
// `cast`, because `transform` is generic on the stream's *runtime* type:
// a transport handing back a `Stream<Uint8List>` (which `package:http`
// does) cannot take a `StreamTransformer<List<int>, String>` and throws
// a TypeError instead of reading the body. Only the error path decodes
// a body here, which is why it went unnoticed.
final body = await response.stream
.cast<List<int>>()
.transform(utf8.decoder)
.join();
throw ServerException.fromBody(
message: response.reasonPhrase ?? 'Unknown error',
statusCode: response.statusCode,
body: body,
);
}
if (response.headers['set-cookie'] case final String cookies
when cookies.isNotEmpty) {
final parser = CookieParser(cookies);
if (parser.parse() case final cookies when cookies.isNotEmpty) {
await storage.saveAll(cookies);
}
}
return response;
}