Merge branch 'master' into client_xhr_transport_test

This commit is contained in:
Moritz 2025-02-14 08:59:05 -08:00 committed by Aran Donohue
commit 16dbabd7c6
No known key found for this signature in database
GPG Key ID: EA98761C2C20A998
5 changed files with 159 additions and 53 deletions

View File

@ -6,6 +6,7 @@
the connection, as defined in the gRPC spec. the connection, as defined in the gRPC spec.
* Upgrade to `package:lints` version 5.0.0 and Dart SDK version 3.5.0. * Upgrade to `package:lints` version 5.0.0 and Dart SDK version 3.5.0.
* Upgrade `example/grpc-web` code. * Upgrade `example/grpc-web` code.
* Update xhr transport to migrate off legacy JS/HTML apis.
## 4.0.1 ## 4.0.1

View File

@ -14,7 +14,7 @@
// limitations under the License. // limitations under the License.
import 'src/client/grpc_or_grpcweb_channel_grpc.dart' import 'src/client/grpc_or_grpcweb_channel_grpc.dart'
if (dart.library.html) 'src/client/grpc_or_grpcweb_channel_web.dart'; if (dart.library.js_interop) 'src/client/grpc_or_grpcweb_channel_web.dart';
import 'src/client/http2_channel.dart'; import 'src/client/http2_channel.dart';
import 'src/client/options.dart'; import 'src/client/options.dart';

View File

@ -14,11 +14,11 @@
// limitations under the License. // limitations under the License.
import 'dart:async'; import 'dart:async';
// ignore: deprecated_member_use (#756) import 'dart:js_interop';
import 'dart:html';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:web/web.dart';
import '../../client/call.dart'; import '../../client/call.dart';
import '../../shared/message.dart'; import '../../shared/message.dart';
@ -31,7 +31,7 @@ import 'web_streams.dart';
const _contentTypeKey = 'Content-Type'; const _contentTypeKey = 'Content-Type';
class XhrTransportStream implements GrpcTransportStream { class XhrTransportStream implements GrpcTransportStream {
final HttpRequest _request; final XMLHttpRequest _request;
final ErrorHandler _onError; final ErrorHandler _onError;
final Function(XhrTransportStream stream) _onDone; final Function(XhrTransportStream stream) _onDone;
bool _headersReceived = false; bool _headersReceived = false;
@ -50,19 +50,20 @@ class XhrTransportStream implements GrpcTransportStream {
{required ErrorHandler onError, required onDone}) {required ErrorHandler onError, required onDone})
: _onError = onError, : _onError = onError,
_onDone = onDone { _onDone = onDone {
_outgoingMessages.stream _outgoingMessages.stream.map(frame).listen(
.map(frame) (data) => _request.send(Uint8List.fromList(data).toJS),
.listen((data) => _request.send(data), cancelOnError: true); cancelOnError: true,
onError: _onError);
_request.onReadyStateChange.listen((data) { _request.onReadyStateChange.listen((_) {
if (_incomingProcessor.isClosed) { if (_incomingProcessor.isClosed) {
return; return;
} }
switch (_request.readyState) { switch (_request.readyState) {
case HttpRequest.HEADERS_RECEIVED: case XMLHttpRequest.HEADERS_RECEIVED:
_onHeadersReceived(); _onHeadersReceived();
break; break;
case HttpRequest.DONE: case XMLHttpRequest.DONE:
_onRequestDone(); _onRequestDone();
_close(); _close();
break; break;
@ -82,13 +83,11 @@ class XhrTransportStream implements GrpcTransportStream {
if (_incomingProcessor.isClosed) { if (_incomingProcessor.isClosed) {
return; return;
} }
// Use response over responseText as most browsers don't support final responseText = _request.responseText;
// using responseText during an onProgress event.
final responseString = _request.response as String;
final bytes = Uint8List.fromList( final bytes = Uint8List.fromList(
responseString.substring(_requestBytesRead).codeUnits) responseText.substring(_requestBytesRead).codeUnits)
.buffer; .buffer;
_requestBytesRead = responseString.length; _requestBytesRead = responseText.length;
_incomingProcessor.add(bytes); _incomingProcessor.add(bytes);
}); });
@ -123,9 +122,11 @@ class XhrTransportStream implements GrpcTransportStream {
if (!_headersReceived && !_validateResponseState()) { if (!_headersReceived && !_validateResponseState()) {
return; return;
} }
if (_request.response == null) { if (_request.status != 200) {
_onError( _onError(
GrpcError.unavailable('XhrConnection request null response', null, GrpcError.unavailable(
'Request failed with status: ${_request.status}',
null,
_request.responseText), _request.responseText),
StackTrace.current); StackTrace.current);
return; return;
@ -145,6 +146,104 @@ class XhrTransportStream implements GrpcTransportStream {
} }
} }
// XMLHttpRequest is an extension type and can't be extended or implemented.
// This interface is used to allow for mocking XMLHttpRequest in tests of
// XhrClientConnection.
@visibleForTesting
abstract interface class IXMLHttpRequest {
Stream<Event> get onReadyStateChange;
Stream<ProgressEvent> get onProgress;
Stream<ProgressEvent> get onError;
int get readyState;
JSAny? get response;
String get responseText;
Map<String, String> get responseHeaders;
int get status;
set responseType(String responseType);
set withCredentials(bool withCredentials);
void open(
String method,
String url, [
// external default is true
bool async = true,
String? username,
String? password,
]);
void overrideMimeType(String mimeType);
void send([JSAny? body]);
void setRequestHeader(String header, String value);
// This method should only be used in production code.
XMLHttpRequest toXMLHttpRequest();
}
// IXMLHttpRequest that delegates to a real XMLHttpRequest.
class XMLHttpRequestImpl implements IXMLHttpRequest {
final XMLHttpRequest _xhr = XMLHttpRequest();
XMLHttpRequestImpl();
@override
Stream<Event> get onReadyStateChange => _xhr.onReadyStateChange;
@override
Stream<ProgressEvent> get onProgress => _xhr.onProgress;
@override
Stream<ProgressEvent> get onError => _xhr.onError;
@override
int get readyState => _xhr.readyState;
@override
Map<String, String> get responseHeaders => _xhr.responseHeaders;
@override
JSAny? get response => _xhr.response;
@override
String get responseText => _xhr.responseText;
@override
int get status => _xhr.status;
@override
set responseType(String responseType) {
_xhr.responseType = responseType;
}
@override
set withCredentials(bool withCredentials) {
_xhr.withCredentials = withCredentials;
}
@override
void open(
String method,
String url, [
bool async = true,
String? username,
String? password,
]) {
_xhr.open(method, url, async, username, password);
}
@override
void overrideMimeType(String mimeType) {
_xhr.overrideMimeType(mimeType);
}
@override
void setRequestHeader(String header, String value) {
_xhr.setRequestHeader(header, value);
}
@override
void send([JSAny? body]) {
_xhr.send(body);
}
@override
XMLHttpRequest toXMLHttpRequest() {
return _xhr;
}
}
class XhrClientConnection implements ClientConnection { class XhrClientConnection implements ClientConnection {
final Uri uri; final Uri uri;
@ -154,20 +253,20 @@ class XhrClientConnection implements ClientConnection {
@override @override
String get authority => uri.authority; String get authority => uri.authority;
@override @override
String get scheme => uri.scheme; String get scheme => uri.scheme;
void _initializeRequest(HttpRequest request, Map<String, String> metadata) { void _initializeRequest(
for (final header in metadata.keys) { IXMLHttpRequest request, Map<String, String> metadata) {
request.setRequestHeader(header, metadata[header]!); metadata.forEach(request.setRequestHeader);
}
// Overriding the mimetype allows us to stream and parse the data // Overriding the mimetype allows us to stream and parse the data
request.overrideMimeType('text/plain; charset=x-user-defined'); request.overrideMimeType('text/plain; charset=x-user-defined');
request.responseType = 'text'; request.responseType = 'text';
} }
@visibleForTesting @visibleForTesting
HttpRequest createHttpRequest() => HttpRequest(); IXMLHttpRequest createHttpRequest() => XMLHttpRequestImpl();
@override @override
GrpcTransportStream makeRequest(String path, Duration? timeout, GrpcTransportStream makeRequest(String path, Duration? timeout,
@ -195,11 +294,17 @@ class XhrClientConnection implements ClientConnection {
_initializeRequest(request, metadata); _initializeRequest(request, metadata);
final transportStream = final transportStream =
XhrTransportStream(request, onError: onError, onDone: _removeStream); _createXhrTransportStream(request, onError, _removeStream);
_requests.add(transportStream); _requests.add(transportStream);
return transportStream; return transportStream;
} }
XhrTransportStream _createXhrTransportStream(IXMLHttpRequest request,
ErrorHandler onError, void Function(XhrTransportStream stream) onDone) {
return XhrTransportStream(request.toXMLHttpRequest(),
onError: onError, onDone: onDone);
}
void _removeStream(XhrTransportStream stream) { void _removeStream(XhrTransportStream stream) {
_requests.remove(stream); _requests.remove(stream);
} }

View File

@ -21,6 +21,7 @@ dependencies:
http2: ^2.2.0 http2: ^2.2.0
protobuf: '>=2.0.0 <4.0.0' protobuf: '>=2.0.0 <4.0.0'
clock: ^1.1.1 clock: ^1.1.1
web: ^1.1.0
dev_dependencies: dev_dependencies:
build_runner: ^2.0.0 build_runner: ^2.0.0

View File

@ -17,8 +17,7 @@
library; library;
import 'dart:async'; import 'dart:async';
// ignore: deprecated_member_use (#756) import 'dart:js_interop';
import 'dart:html';
import 'package:async/async.dart'; import 'package:async/async.dart';
import 'package:grpc/src/client/call.dart'; import 'package:grpc/src/client/call.dart';
@ -28,12 +27,13 @@ import 'package:grpc/src/shared/status.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import 'package:stream_transform/stream_transform.dart'; import 'package:stream_transform/stream_transform.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'package:web/web.dart';
final readyStateChangeEvent = final readyStateChangeEvent =
Event('readystatechange', canBubble: false, cancelable: false); Event('readystatechange', EventInit(bubbles: false, cancelable: false));
final progressEvent = ProgressEvent('onloadstart'); final progressEvent = ProgressEvent('onloadstart');
class MockHttpRequest extends Mock implements HttpRequest { class MockHttpRequest extends Mock implements IXMLHttpRequest {
MockHttpRequest({int? code}) : status = code ?? 200; MockHttpRequest({int? code}) : status = code ?? 200;
// ignore: close_sinks // ignore: close_sinks
StreamController<Event> readyStateChangeController = StreamController<Event> readyStateChangeController =
@ -54,6 +54,10 @@ class MockHttpRequest extends Mock implements HttpRequest {
@override @override
final int status; final int status;
// Some test code expects to call this
set readyState(int state);
set responseText(String text);
@override @override
int get readyState => int get readyState =>
super.noSuchMethod(Invocation.getter(#readyState), returnValue: -1); super.noSuchMethod(Invocation.getter(#readyState), returnValue: -1);
@ -73,7 +77,7 @@ class MockXhrClientConnection extends XhrClientConnection {
final int _statusCode; final int _statusCode;
@override @override
HttpRequest createHttpRequest() { IXMLHttpRequest createHttpRequest() {
final request = MockHttpRequest(code: _statusCode); final request = MockHttpRequest(code: _statusCode);
latestRequest = request; latestRequest = request;
return request; return request;
@ -210,8 +214,7 @@ void main() {
await stream.terminate(); await stream.terminate();
final expectedData = frame(data); final expectedData = frame(data);
expect(verify(connection.latestRequest.send(captureAny)).captured.single, verify(connection.latestRequest.send(expectedData.toJSBox));
expectedData);
}); });
test('Stream handles headers properly', () async { test('Stream handles headers properly', () async {
@ -228,15 +231,15 @@ void main() {
when(transport.latestRequest.responseHeaders).thenReturn(responseHeaders); when(transport.latestRequest.responseHeaders).thenReturn(responseHeaders);
when(transport.latestRequest.response) when(transport.latestRequest.response)
.thenReturn(String.fromCharCodes(frame(<int>[]))); .thenReturn(String.fromCharCodes(frame(<int>[])).toJS);
// Set expectation for request readyState and generate two readyStateChange // Set expectation for request readyState and generate two readyStateChange
// events, so that incomingMessages stream completes. // events, so that incomingMessages stream completes.
final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; final readyStates = [XMLHttpRequest.HEADERS_RECEIVED, XMLHttpRequest.DONE];
when(transport.latestRequest.readyState) transport.latestRequest.readyState = readyStates[0];
.thenAnswer((_) => readyStates.removeAt(0));
transport.latestRequest.readyStateChangeController transport.latestRequest.readyStateChangeController
.add(readyStateChangeEvent); .add(readyStateChangeEvent);
transport.latestRequest.readyState = readyStates[1];
transport.latestRequest.readyStateChangeController transport.latestRequest.readyStateChangeController
.add(readyStateChangeEvent); .add(readyStateChangeEvent);
@ -269,16 +272,15 @@ void main() {
final encodedString = String.fromCharCodes(encodedTrailers); final encodedString = String.fromCharCodes(encodedTrailers);
when(connection.latestRequest.responseHeaders).thenReturn(requestHeaders); when(connection.latestRequest.responseHeaders).thenReturn(requestHeaders);
when(connection.latestRequest.response).thenReturn(encodedString); when(connection.latestRequest.response).thenReturn(encodedString.toJS);
// Set expectation for request readyState and generate events so that // Set expectation for request readyState and generate events so that
// incomingMessages stream completes. // incomingMessages stream completes.
final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; connection.latestRequest.readyState = XMLHttpRequest.HEADERS_RECEIVED;
when(connection.latestRequest.readyState)
.thenAnswer((_) => readyStates.removeAt(0));
connection.latestRequest.readyStateChangeController connection.latestRequest.readyStateChangeController
.add(readyStateChangeEvent); .add(readyStateChangeEvent);
connection.latestRequest.progressController.add(progressEvent); connection.latestRequest.progressController.add(progressEvent);
connection.latestRequest.readyState = XMLHttpRequest.DONE;
connection.latestRequest.readyStateChangeController connection.latestRequest.readyStateChangeController
.add(readyStateChangeEvent); .add(readyStateChangeEvent);
@ -305,16 +307,14 @@ void main() {
final encodedString = String.fromCharCodes(encoded); final encodedString = String.fromCharCodes(encoded);
when(connection.latestRequest.responseHeaders).thenReturn(requestHeaders); when(connection.latestRequest.responseHeaders).thenReturn(requestHeaders);
when(connection.latestRequest.response).thenReturn(encodedString); when(connection.latestRequest.response).thenReturn(encodedString.toJS);
// Set expectation for request readyState and generate events so that // Set expectation for request readyState and generate events so that
// incomingMessages stream completes. // incomingMessages stream completes.
final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; connection.latestRequest.readyState = XMLHttpRequest.HEADERS_RECEIVED;
when(connection.latestRequest.readyState)
.thenAnswer((_) => readyStates.removeAt(0));
connection.latestRequest.readyStateChangeController connection.latestRequest.readyStateChangeController
.add(readyStateChangeEvent); .add(readyStateChangeEvent);
connection.latestRequest.progressController.add(progressEvent); connection.latestRequest.progressController.add(progressEvent);
connection.latestRequest.readyState = XMLHttpRequest.DONE;
connection.latestRequest.readyStateChangeController connection.latestRequest.readyStateChangeController
.add(readyStateChangeEvent); .add(readyStateChangeEvent);
@ -340,16 +340,15 @@ void main() {
final data = List<int>.filled(10, 224); final data = List<int>.filled(10, 224);
when(connection.latestRequest.responseHeaders).thenReturn(requestHeaders); when(connection.latestRequest.responseHeaders).thenReturn(requestHeaders);
when(connection.latestRequest.response) when(connection.latestRequest.response)
.thenReturn(String.fromCharCodes(frame(data))); .thenReturn(String.fromCharCodes(frame(data)).toJS);
// Set expectation for request readyState and generate events, so that // Set expectation for request readyState and generate events, so that
// incomingMessages stream completes. // incomingMessages stream completes.
final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; connection.latestRequest.readyState = XMLHttpRequest.HEADERS_RECEIVED;
when(connection.latestRequest.readyState)
.thenAnswer((_) => readyStates.removeAt(0));
connection.latestRequest.readyStateChangeController connection.latestRequest.readyStateChangeController
.add(readyStateChangeEvent); .add(readyStateChangeEvent);
connection.latestRequest.progressController.add(progressEvent); connection.latestRequest.progressController.add(progressEvent);
connection.latestRequest.readyState = XMLHttpRequest.DONE;
connection.latestRequest.readyStateChangeController connection.latestRequest.readyStateChangeController
.add(readyStateChangeEvent); .add(readyStateChangeEvent);
@ -371,8 +370,8 @@ void main() {
const errorDetails = 'error details'; const errorDetails = 'error details';
when(connection.latestRequest.responseHeaders) when(connection.latestRequest.responseHeaders)
.thenReturn({'content-type': 'application/grpc+proto'}); .thenReturn({'content-type': 'application/grpc+proto'});
when(connection.latestRequest.readyState).thenReturn(HttpRequest.DONE); connection.latestRequest.readyState = XMLHttpRequest.DONE;
when(connection.latestRequest.responseText).thenReturn(errorDetails); connection.latestRequest.responseText = errorDetails;
connection.latestRequest.readyStateChangeController connection.latestRequest.readyStateChangeController
.add(readyStateChangeEvent); .add(readyStateChangeEvent);
await errorReceived.future; await errorReceived.future;
@ -400,7 +399,7 @@ void main() {
when(connection.latestRequest.responseHeaders).thenReturn(metadata); when(connection.latestRequest.responseHeaders).thenReturn(metadata);
when(connection.latestRequest.readyState) when(connection.latestRequest.readyState)
.thenReturn(HttpRequest.HEADERS_RECEIVED); .thenReturn(XMLHttpRequest.HEADERS_RECEIVED);
// At first invocation the response should be the the first message, after // At first invocation the response should be the the first message, after
// that first + last messages. // that first + last messages.
@ -408,12 +407,12 @@ void main() {
when(connection.latestRequest.response).thenAnswer((_) { when(connection.latestRequest.response).thenAnswer((_) {
if (first) { if (first) {
first = false; first = false;
return encodedStrings[0]; return encodedStrings[0].toJS;
} }
return encodedStrings[0] + encodedStrings[1]; return (encodedStrings[0] + encodedStrings[1]).toJS;
}); });
final readyStates = [HttpRequest.HEADERS_RECEIVED, HttpRequest.DONE]; final readyStates = [XMLHttpRequest.HEADERS_RECEIVED, XMLHttpRequest.DONE];
when(connection.latestRequest.readyState) when(connection.latestRequest.readyState)
.thenAnswer((_) => readyStates.removeAt(0)); .thenAnswer((_) => readyStates.removeAt(0));