blob: 0294ffe77c1b9f4b80c5c40b43272fb40a222d6d [file]
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'package:http2/src/http2_client.dart';
import 'package:http2/transport.dart';
import 'package:http_client_conformance_tests/http_client_conformance_tests.dart';
import 'package:test/test.dart';
class Http2ProxyServer {
final SecureServerSocket _socket;
final List<ServerTransportConnection> _connections = [];
final IOClient _httpClient = IOClient();
Http2ProxyServer._(this._socket) {
_socket.listen((socket) {
final connection = ServerTransportConnection.viaSocket(socket);
_connections.add(connection);
connection.incomingStreams.listen(_handleStream);
});
}
static Future<Http2ProxyServer> start() async {
final context =
SecurityContext()
..useCertificateChain('test/certificates/server_chain.pem')
..usePrivateKey(
'test/certificates/server_key.pem',
password: 'dartdart',
)
..setAlpnProtocols(['h2'], true);
final socket = await SecureServerSocket.bind('localhost', 0, context);
return Http2ProxyServer._(socket);
}
int get port => _socket.port;
Future<void> _handleStream(ServerTransportStream stream) async {
try {
final messages = StreamIterator(stream.incomingMessages);
if (!await messages.moveNext()) return;
final headersMsg = messages.current as HeadersStreamMessage;
String? method;
String? path;
int? targetPort;
final headers = <String, String>{};
for (final header in headersMsg.headers) {
final name = ascii.decode(header.name);
final value = ascii.decode(header.value);
if (name == ':method') {
method = value;
} else if (name == ':path') {
path = value;
} else if (name == 'x-target-port') {
targetPort = int.parse(value);
} else if (!name.startsWith(':')) {
headers[name] = value;
}
}
if (method == null || path == null || targetPort == null) {
stream.outgoingMessages.add(
HeadersStreamMessage([Header.ascii(':status', '400')]),
);
await stream.outgoingMessages.close();
return;
}
// Collect body
final bodyBytes = <int>[];
while (await messages.moveNext()) {
final msg = messages.current;
if (msg is DataStreamMessage) {
bodyBytes.addAll(msg.bytes);
}
}
// Forward to HTTP/1.1 server
final targetUri = Uri.parse('http://localhost:$targetPort$path');
final httpRequest = http.Request(method, targetUri);
headers.forEach((k, v) {
httpRequest.headers[k] = v;
});
httpRequest.bodyBytes = bodyBytes;
final httpResponse = await _httpClient.send(httpRequest);
// Send response headers
final responseHeaders = <Header>[
Header.ascii(':status', httpResponse.statusCode.toString()),
];
httpResponse.headers.forEach((k, v) {
responseHeaders.add(Header.ascii(k.toLowerCase(), v));
});
stream.outgoingMessages.add(HeadersStreamMessage(responseHeaders));
// Send response body
await for (final chunk in httpResponse.stream) {
stream.outgoingMessages.add(DataStreamMessage(chunk));
}
await stream.outgoingMessages.close();
} catch (e) {
print('Proxy error: $e');
stream.terminate();
}
}
Future<void> close() async {
await _socket.close();
for (final conn in _connections) {
await conn.terminate();
}
_httpClient.close();
}
}
class ProxyRequest extends http.BaseRequest implements http.Abortable {
// `url` is not redeclared here - BaseRequest's own constructor stores it.
ProxyRequest(this._original, Uri url) : super(_original.method, url);
final http.BaseRequest _original;
@override
Future<void>? get abortTrigger =>
_original is http.Abortable ? _original.abortTrigger : null;
@override
Map<String, String> get headers => _original.headers;
@override
int? get contentLength => _original.contentLength;
@override
set contentLength(int? value) => _original.contentLength = value;
@override
bool get followRedirects => _original.followRedirects;
@override
set followRedirects(bool value) => _original.followRedirects = value;
@override
int get maxRedirects => _original.maxRedirects;
@override
set maxRedirects(int value) => _original.maxRedirects = value;
@override
bool get persistentConnection => _original.persistentConnection;
@override
set persistentConnection(bool value) =>
_original.persistentConnection = value;
@override
http.ByteStream finalize() {
super.finalize();
return _original.finalize();
}
}
class ConformanceProxyClient extends http.BaseClient {
final Http2Client _inner;
final int _proxyPort;
ConformanceProxyClient(this._inner, this._proxyPort);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final targetPort = request.url.port;
final proxyUrl = request.url.replace(
scheme: 'https',
host: 'localhost',
port: _proxyPort,
);
request.headers['x-target-port'] = targetPort.toString();
final proxyRequest = ProxyRequest(request, proxyUrl);
try {
final response = await _inner.send(proxyRequest);
return http.StreamedResponse(
response.stream,
response.statusCode,
contentLength: response.contentLength,
headers: response.headers,
isRedirect: response.isRedirect,
persistentConnection: response.persistentConnection,
reasonPhrase: response.reasonPhrase,
request: request,
);
} on http.ClientException catch (e) {
if (e is http.RequestAbortedException) {
throw http.RequestAbortedException(request.url);
}
throw http.ClientException(e.message, request.url);
}
}
@override
void close() {
_inner.close();
}
}
/// [Http2Client] only supports HTTP/2 over TLS (HTTPS). However, the standard
/// servers started by http_client_conformance_tests only support unencrypted
/// HTTP/1.1.
/// To bridge this protocol gap, we run a local HTTP/2 proxy server
/// ([Http2ProxyServer]) in-process. [ConformanceProxyClient] wraps
/// [Http2Client] and rewrites the destination URI of all outgoing requests to
/// point to the local proxy server, attaching a custom `x-target-port` header
/// to specify the target HTTP/1.1 server port. The proxy server then forwards
/// the request over HTTP/1.1 and returns the response to [Http2Client] over
/// HTTP/2.
void main() {
late final Http2ProxyServer proxy;
setUpAll(() async {
proxy = await Http2ProxyServer.start();
});
tearDownAll(() async {
await proxy.close();
});
ConformanceProxyClient clientFactory() => ConformanceProxyClient(
Http2Client(onBadCertificate: (_) => true),
proxy.port,
);
testRequestBody(clientFactory);
// TODO: Implement request body streaming support in Http2Client.
// Currently Http2Client reads the entire request body into memory before
// sending.
testRequestBodyStreamed(clientFactory, canStreamRequestBody: false);
testResponseBody(clientFactory);
// TODO: Re-enable once request abort support is implemented in Http2Client.
// testResponseBodyStreamed(clientFactory);
testRequestHeaders(clientFactory);
testRequestMethods(clientFactory, preservesMethodCase: false);
testResponseHeaders(
clientFactory,
// HTTP/2 explicitly forbids folded headers (RFC 7540 Section 8.1.2.6).
supportsFoldedHeaders: false,
// HTTP/2 does not allow NUL characters inside header names or values.
correctlyHandlesNullHeaderValues: false,
);
testResponseStatusLine(clientFactory);
// TODO: Implement redirect-following support in Http2Client.
// testRedirect(clientFactory);
testServerErrors(clientFactory);
testCompressedResponseBody(clientFactory);
testMultipleClients(clientFactory);
testMultipartRequests(clientFactory, supportsMultipartRequest: true);
testClose(clientFactory);
// TODO: Support running client conformance tests in isolates.
// Currently we set `canWorkInIsolates` to false because the proxy server uses
// `SecureServerSocket`, which cannot be sent across isolates.
testIsolate(clientFactory, canWorkInIsolates: false);
testRequestCookies(clientFactory, canSendCookieHeaders: true);
testResponseCookies(clientFactory, canReceiveSetCookieHeaders: true);
// TODO: Implement request abort support in Http2Client.
// testAbort(
// clientFactory,
// supportsAbort: true,
// canStreamRequestBody: false,
// canStreamResponseBody: true,
// );
}