request method

Future<HttpResponse> request({
  1. required String method,
  2. required String path,
  3. Map<String, dynamic>? headers,
  4. Object? body,
  5. Map<String, dynamic> query = const {},
})

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