mirror of https://github.com/grpc/grpc-dart.git
Add browser features to Dart gRPC-web for parity with JS implementation. (#347)
* Add bypassCorsPreflight capability to gRPC-web Dart. * fix tests and run dartfmt. * remove print statement * dartfmt * Update pubspec. * Fix changelog. * Fix tests * final dartfmt * Respond to requested changes. * revert extra newline in changelog Co-authored-by: Michael Thomsen <mit@google.com>
This commit is contained in:
parent
dd34af2de4
commit
bb4eab0f1f
|
@ -1,3 +1,7 @@
|
||||||
|
## 2.4.0
|
||||||
|
|
||||||
|
* Add the ability to bypass CORS preflight requests.
|
||||||
|
|
||||||
## 2.3.0
|
## 2.3.0
|
||||||
|
|
||||||
* Revert [PR #287](https://github.com/grpc/grpc-dart/pull/287), which allowed
|
* Revert [PR #287](https://github.com/grpc/grpc-dart/pull/287), which allowed
|
||||||
|
|
|
@ -16,7 +16,8 @@
|
||||||
export 'src/auth/auth.dart'
|
export 'src/auth/auth.dart'
|
||||||
show HttpBasedAuthenticator, JwtServiceAccountAuthenticator;
|
show HttpBasedAuthenticator, JwtServiceAccountAuthenticator;
|
||||||
|
|
||||||
export 'src/client/call.dart' show MetadataProvider, CallOptions;
|
export 'src/client/call.dart'
|
||||||
|
show MetadataProvider, CallOptions, WebCallOptions;
|
||||||
|
|
||||||
export 'src/client/common.dart' show Response, ResponseStream, ResponseFuture;
|
export 'src/client/common.dart' show Response, ResponseStream, ResponseFuture;
|
||||||
|
|
||||||
|
|
|
@ -80,6 +80,69 @@ class CallOptions {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runtime options for gRPC-web.
|
||||||
|
class WebCallOptions extends CallOptions {
|
||||||
|
/// Whether to eliminate the CORS preflight request.
|
||||||
|
///
|
||||||
|
/// If set to [true], all HTTP headers will be packed into an '$httpHeaders'
|
||||||
|
/// query parameter, which should downgrade complex CORS requests into
|
||||||
|
/// simple ones. This eliminates an extra roundtrip.
|
||||||
|
///
|
||||||
|
/// For this to work correctly, a proxy server must be set up that
|
||||||
|
/// understands the query parameter and can unpack/send the original
|
||||||
|
/// list of headers to the server endpoint.
|
||||||
|
final bool bypassCorsPreflight;
|
||||||
|
|
||||||
|
/// Whether to send credentials along with the XHR.
|
||||||
|
///
|
||||||
|
/// This may be required for proxying or wherever the server
|
||||||
|
/// needs to otherwise inspect client cookies for that domain.
|
||||||
|
final bool withCredentials;
|
||||||
|
// TODO(mightyvoice): add a list of extra QueryParameter for gRPC.
|
||||||
|
|
||||||
|
WebCallOptions._(Map<String, String> metadata, Duration timeout,
|
||||||
|
List<MetadataProvider> metadataProviders,
|
||||||
|
{this.bypassCorsPreflight, this.withCredentials})
|
||||||
|
: super._(metadata, timeout, metadataProviders);
|
||||||
|
|
||||||
|
/// Creates a [WebCallOptions] object.
|
||||||
|
///
|
||||||
|
/// [WebCallOptions] can specify static [metadata], [timeout],
|
||||||
|
/// metadata [providers] of [CallOptions], [bypassCorsPreflight] and
|
||||||
|
/// [withCredentials] for CORS request.
|
||||||
|
factory WebCallOptions(
|
||||||
|
{Map<String, String> metadata,
|
||||||
|
Duration timeout,
|
||||||
|
List<MetadataProvider> providers,
|
||||||
|
bool bypassCorsPreflight,
|
||||||
|
bool withCredentials}) {
|
||||||
|
return WebCallOptions._(Map.unmodifiable(metadata ?? {}), timeout,
|
||||||
|
List.unmodifiable(providers ?? []),
|
||||||
|
bypassCorsPreflight: bypassCorsPreflight ?? false,
|
||||||
|
withCredentials: withCredentials ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
CallOptions mergedWith(CallOptions other) {
|
||||||
|
if (other == null) return this;
|
||||||
|
if (other is! WebCallOptions) return super.mergedWith(other);
|
||||||
|
|
||||||
|
final otherOptions = other as WebCallOptions;
|
||||||
|
final mergedBypassCorsPreflight =
|
||||||
|
otherOptions.bypassCorsPreflight ?? bypassCorsPreflight;
|
||||||
|
final mergedWithCredentials =
|
||||||
|
otherOptions.withCredentials ?? withCredentials;
|
||||||
|
final mergedMetadata = Map.from(metadata)..addAll(otherOptions.metadata);
|
||||||
|
final mergedTimeout = otherOptions.timeout ?? timeout;
|
||||||
|
final mergedProviders = List.from(metadataProviders)
|
||||||
|
..addAll(otherOptions.metadataProviders);
|
||||||
|
return WebCallOptions._(Map.unmodifiable(mergedMetadata), mergedTimeout,
|
||||||
|
List.unmodifiable(mergedProviders),
|
||||||
|
bypassCorsPreflight: mergedBypassCorsPreflight,
|
||||||
|
withCredentials: mergedWithCredentials);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An active call to a gRPC endpoint.
|
/// An active call to a gRPC endpoint.
|
||||||
class ClientCall<Q, R> implements Response {
|
class ClientCall<Q, R> implements Response {
|
||||||
final ClientMethod<Q, R> _method;
|
final ClientMethod<Q, R> _method;
|
||||||
|
|
|
@ -45,7 +45,8 @@ abstract class ClientConnection {
|
||||||
|
|
||||||
/// Start a request for [path] with [metadata].
|
/// Start a request for [path] with [metadata].
|
||||||
GrpcTransportStream makeRequest(String path, Duration timeout,
|
GrpcTransportStream makeRequest(String path, Duration timeout,
|
||||||
Map<String, String> metadata, ErrorHandler onRequestFailure);
|
Map<String, String> metadata, ErrorHandler onRequestFailure,
|
||||||
|
{CallOptions callOptions});
|
||||||
|
|
||||||
/// Shuts down this connection.
|
/// Shuts down this connection.
|
||||||
///
|
///
|
||||||
|
|
|
@ -178,7 +178,8 @@ class Http2ClientConnection implements connection.ClientConnection {
|
||||||
}
|
}
|
||||||
|
|
||||||
GrpcTransportStream makeRequest(String path, Duration timeout,
|
GrpcTransportStream makeRequest(String path, Duration timeout,
|
||||||
Map<String, String> metadata, ErrorHandler onRequestFailure) {
|
Map<String, String> metadata, ErrorHandler onRequestFailure,
|
||||||
|
{CallOptions callOptions}) {
|
||||||
final headers = createCallHeaders(
|
final headers = createCallHeaders(
|
||||||
credentials.isSecure, authority, path, timeout, metadata,
|
credentials.isSecure, authority, path, timeout, metadata,
|
||||||
userAgent: options.userAgent);
|
userAgent: options.userAgent);
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
// Copyright 2018 Google Inc. All Rights Reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
/// Enclosing class for query parameters that can accommodate a [List] of
|
||||||
|
/// [String] values.
|
||||||
|
///
|
||||||
|
/// Note: though query param values can be [String], [int], or [Int64], this
|
||||||
|
/// simple class always expects a [String]. This is to avoid [dynamic] typing,
|
||||||
|
/// plus the whole thing will be converted to [String] later anyway.
|
||||||
|
class QueryParameter implements Comparable<QueryParameter> {
|
||||||
|
/// The parameter key.
|
||||||
|
final String key;
|
||||||
|
|
||||||
|
/// The list of parameter values.
|
||||||
|
final List<String> values;
|
||||||
|
|
||||||
|
/// Convenience method for single-value params.
|
||||||
|
String get value => values.first;
|
||||||
|
|
||||||
|
/// Creates an instance by wrapping the single value in a [List].
|
||||||
|
QueryParameter(this.key, String value)
|
||||||
|
: assert(key != null && key.trim().isNotEmpty),
|
||||||
|
values = [value];
|
||||||
|
|
||||||
|
/// Creates an instance from a [List] of values.
|
||||||
|
///
|
||||||
|
/// This is not the default constructor since the single-value case is the
|
||||||
|
/// most common.
|
||||||
|
QueryParameter.multi(this.key, List<String> values)
|
||||||
|
: assert(key != null && key.trim().isNotEmpty),
|
||||||
|
values = values..sort();
|
||||||
|
|
||||||
|
/// Returns the escaped value of the param as will appear in a URL.
|
||||||
|
@override
|
||||||
|
String toString() => values.map(_encode).join('&');
|
||||||
|
|
||||||
|
/// Returns the encoded version of a single key-value pair.
|
||||||
|
String _encode(String value) {
|
||||||
|
final safeKey = Uri.encodeQueryComponent(key);
|
||||||
|
final safeVal = Uri.encodeQueryComponent(value);
|
||||||
|
return '$safeKey=$safeVal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compares this to another [QueryParameter] based on its string value.
|
||||||
|
@override
|
||||||
|
int compareTo(QueryParameter other) => toString().compareTo(other.toString());
|
||||||
|
|
||||||
|
static String buildQuery(List<QueryParameter> queryParams) {
|
||||||
|
if (queryParams.isEmpty) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
final buf = StringBuffer();
|
||||||
|
// Sorts the query params to ensure a canonical path.
|
||||||
|
final sortedParamValues = queryParams
|
||||||
|
..sort()
|
||||||
|
..map((param) => param.toString());
|
||||||
|
buf.writeAll(sortedParamValues, '&');
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
// Copyright 2018 Google Inc. All Rights Reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
// Provides CORS support for HTTP based RPC requests.
|
||||||
|
|
||||||
|
/// The default URL parameter name to overwrite http headers with a URL param
|
||||||
|
/// to avoid CORS preflight.
|
||||||
|
///
|
||||||
|
/// This comes from the JS impl details at
|
||||||
|
/// https://github.com/whatwg/fetch/issues/210#issue-129531743.
|
||||||
|
const _httpHeadersParamName = r'$httpHeaders';
|
||||||
|
|
||||||
|
// TODO(mightyvoice): Add the const parameter name for HTTP method if not
|
||||||
|
// always use POST for gRPC-web.
|
||||||
|
|
||||||
|
/// Manipulates the path and headers of the http request such that it will
|
||||||
|
/// not trigger a CORS preflight request and returns the new path with extra
|
||||||
|
/// query parameters.
|
||||||
|
///
|
||||||
|
/// A proxy server that understands the '$httpHeaders' query parameter
|
||||||
|
/// is required for this to work correctly.
|
||||||
|
Uri moveHttpHeadersToQueryParam(Map<String, String> metadata, Uri requestUri) {
|
||||||
|
// Nothing to do if there are no headers.
|
||||||
|
if (metadata.isEmpty) {
|
||||||
|
return requestUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
final paramValue = _generateHttpHeadersOverwriteParam(metadata);
|
||||||
|
metadata.clear();
|
||||||
|
return requestUri.replace(
|
||||||
|
queryParameters: Map.of(requestUri.queryParameters)
|
||||||
|
..[_httpHeadersParamName] = paramValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates the URL parameter value with custom headers encoded as
|
||||||
|
/// HTTP/1.1 headers block.
|
||||||
|
String _generateHttpHeadersOverwriteParam(Map<String, String> headers) =>
|
||||||
|
headers.entries.map((e) => '${e.key}:${e.value}\r\n').join();
|
|
@ -23,9 +23,19 @@ import '../../client/call.dart';
|
||||||
import '../../shared/message.dart';
|
import '../../shared/message.dart';
|
||||||
import '../../shared/status.dart';
|
import '../../shared/status.dart';
|
||||||
import '../connection.dart';
|
import '../connection.dart';
|
||||||
|
import 'cors.dart' as cors;
|
||||||
import 'transport.dart';
|
import 'transport.dart';
|
||||||
import 'web_streams.dart';
|
import 'web_streams.dart';
|
||||||
|
|
||||||
|
const _contentTypeKey = 'Content-Type';
|
||||||
|
|
||||||
|
/// All accepted content-type header's prefix.
|
||||||
|
const _validContentTypePrefix = [
|
||||||
|
'application/grpc',
|
||||||
|
'application/json+protobuf',
|
||||||
|
'application/x-protobuf'
|
||||||
|
];
|
||||||
|
|
||||||
class XhrTransportStream implements GrpcTransportStream {
|
class XhrTransportStream implements GrpcTransportStream {
|
||||||
final HttpRequest _request;
|
final HttpRequest _request;
|
||||||
final ErrorHandler _onError;
|
final ErrorHandler _onError;
|
||||||
|
@ -96,8 +106,12 @@ class XhrTransportStream implements GrpcTransportStream {
|
||||||
onError: _onError, onDone: _incomingMessages.close);
|
onError: _onError, onDone: _incomingMessages.close);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _checkContentType(String contentType) {
|
||||||
|
return _validContentTypePrefix.any(contentType.startsWith);
|
||||||
|
}
|
||||||
|
|
||||||
_onHeadersReceived() {
|
_onHeadersReceived() {
|
||||||
final contentType = _request.getResponseHeader('Content-Type');
|
final contentType = _request.getResponseHeader(_contentTypeKey);
|
||||||
if (_request.status != 200) {
|
if (_request.status != 200) {
|
||||||
_onError(
|
_onError(
|
||||||
GrpcError.unavailable('XhrConnection status ${_request.status}'));
|
GrpcError.unavailable('XhrConnection status ${_request.status}'));
|
||||||
|
@ -107,7 +121,7 @@ class XhrTransportStream implements GrpcTransportStream {
|
||||||
_onError(GrpcError.unavailable('XhrConnection missing Content-Type'));
|
_onError(GrpcError.unavailable('XhrConnection missing Content-Type'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!contentType.startsWith('application/grpc')) {
|
if (!_checkContentType(contentType)) {
|
||||||
_onError(
|
_onError(
|
||||||
GrpcError.unavailable('XhrConnection bad Content-Type $contentType'));
|
GrpcError.unavailable('XhrConnection bad Content-Type $contentType'));
|
||||||
return;
|
return;
|
||||||
|
@ -162,10 +176,27 @@ class XhrClientConnection extends ClientConnection {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GrpcTransportStream makeRequest(String path, Duration timeout,
|
GrpcTransportStream makeRequest(String path, Duration timeout,
|
||||||
Map<String, String> metadata, ErrorHandler onError) {
|
Map<String, String> metadata, ErrorHandler onError,
|
||||||
final HttpRequest request = createHttpRequest();
|
{CallOptions callOptions}) {
|
||||||
request.open('POST', uri.resolve(path).toString());
|
// gRPC-web headers.
|
||||||
|
if (_getContentTypeHeader(metadata) == null) {
|
||||||
|
metadata['Content-Type'] = 'application/grpc-web+proto';
|
||||||
|
metadata['X-User-Agent'] = 'grpc-web-dart/0.1';
|
||||||
|
metadata['X-Grpc-Web'] = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestUri = uri.resolve(path);
|
||||||
|
if (callOptions is WebCallOptions &&
|
||||||
|
callOptions.bypassCorsPreflight == true) {
|
||||||
|
requestUri = cors.moveHttpHeadersToQueryParam(metadata, requestUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
final HttpRequest request = createHttpRequest();
|
||||||
|
request.open('POST', requestUri.toString());
|
||||||
|
if (callOptions is WebCallOptions && callOptions.withCredentials == true) {
|
||||||
|
request.withCredentials = true;
|
||||||
|
}
|
||||||
|
// Must set headers after calling open().
|
||||||
_initializeRequest(request, metadata);
|
_initializeRequest(request, metadata);
|
||||||
|
|
||||||
final XhrTransportStream transportStream =
|
final XhrTransportStream transportStream =
|
||||||
|
@ -193,3 +224,12 @@ class XhrClientConnection extends ClientConnection {
|
||||||
@override
|
@override
|
||||||
Future<void> shutdown() async {}
|
Future<void> shutdown() async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MapEntry<String, String> _getContentTypeHeader(Map<String, String> metadata) {
|
||||||
|
for (var entry in metadata.entries) {
|
||||||
|
if (entry.key.toLowerCase() == _contentTypeKey.toLowerCase()) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
name: grpc
|
name: grpc
|
||||||
description: Dart implementation of gRPC, a high performance, open-source universal RPC framework.
|
description: Dart implementation of gRPC, a high performance, open-source universal RPC framework.
|
||||||
|
|
||||||
version: 2.3.0
|
version: 2.4.0
|
||||||
|
|
||||||
author: Dart Team <misc@dartlang.org>
|
author: Dart Team <misc@dartlang.org>
|
||||||
homepage: https://github.com/dart-lang/grpc-dart
|
homepage: https://github.com/dart-lang/grpc-dart
|
||||||
|
|
|
@ -18,6 +18,7 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'dart:html';
|
import 'dart:html';
|
||||||
|
|
||||||
|
import 'package:grpc/src/client/call.dart';
|
||||||
import 'package:grpc/src/client/transport/xhr_transport.dart';
|
import 'package:grpc/src/client/transport/xhr_transport.dart';
|
||||||
import 'package:grpc/src/shared/message.dart';
|
import 'package:grpc/src/shared/message.dart';
|
||||||
import 'package:mockito/mockito.dart';
|
import 'package:mockito/mockito.dart';
|
||||||
|
@ -80,6 +81,98 @@ void main() {
|
||||||
verify(connection.latestRequest.responseType = 'text');
|
verify(connection.latestRequest.responseType = 'text');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Make request sends correct headers and path if bypassCorsPreflight=true',
|
||||||
|
() async {
|
||||||
|
final metadata = {'header_1': 'value_1', 'header_2': 'value_2'};
|
||||||
|
final connection = MockXhrClientConnection();
|
||||||
|
|
||||||
|
connection.makeRequest('path', Duration(seconds: 10), metadata,
|
||||||
|
(error) => fail(error.toString()),
|
||||||
|
callOptions: WebCallOptions(bypassCorsPreflight: true));
|
||||||
|
|
||||||
|
expect(metadata, isEmpty);
|
||||||
|
verify(connection.latestRequest.open('POST',
|
||||||
|
'test:path?%24httpHeaders=header_1%3Avalue_1%0D%0Aheader_2%3Avalue_2%0D%0AContent-Type%3Aapplication%2Fgrpc-web%2Bproto%0D%0AX-User-Agent%3Agrpc-web-dart%2F0.1%0D%0AX-Grpc-Web%3A1%0D%0A'));
|
||||||
|
verify(connection.latestRequest
|
||||||
|
.overrideMimeType('text/plain; charset=x-user-defined'));
|
||||||
|
verify(connection.latestRequest.responseType = 'text');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Make request sends correct headers if call options already have '
|
||||||
|
'Content-Type header', () async {
|
||||||
|
final metadata = {
|
||||||
|
'header_1': 'value_1',
|
||||||
|
'header_2': 'value_2',
|
||||||
|
'Content-Type': 'application/json+protobuf'
|
||||||
|
};
|
||||||
|
final connection = MockXhrClientConnection();
|
||||||
|
|
||||||
|
connection.makeRequest('/path', Duration(seconds: 10), metadata,
|
||||||
|
(error) => fail(error.toString()));
|
||||||
|
|
||||||
|
expect(metadata, {
|
||||||
|
'header_1': 'value_1',
|
||||||
|
'header_2': 'value_2',
|
||||||
|
'Content-Type': 'application/json+protobuf',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Content-Type header case insensitivity', () async {
|
||||||
|
final metadata = {
|
||||||
|
'header_1': 'value_1',
|
||||||
|
'CONTENT-TYPE': 'application/json+protobuf'
|
||||||
|
};
|
||||||
|
final connection = MockXhrClientConnection();
|
||||||
|
|
||||||
|
connection.makeRequest('/path', Duration(seconds: 10), metadata,
|
||||||
|
(error) => fail(error.toString()));
|
||||||
|
expect(metadata, {
|
||||||
|
'header_1': 'value_1',
|
||||||
|
'CONTENT-TYPE': 'application/json+protobuf',
|
||||||
|
});
|
||||||
|
|
||||||
|
final lowerMetadata = {
|
||||||
|
'header_1': 'value_1',
|
||||||
|
'content-type': 'application/json+protobuf'
|
||||||
|
};
|
||||||
|
connection.makeRequest('/path', Duration(seconds: 10), lowerMetadata,
|
||||||
|
(error) => fail(error.toString()));
|
||||||
|
expect(lowerMetadata, {
|
||||||
|
'header_1': 'value_1',
|
||||||
|
'content-type': 'application/json+protobuf',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Make request sends correct headers path if only withCredentials=true',
|
||||||
|
() async {
|
||||||
|
final metadata = {'header_1': 'value_1', 'header_2': 'value_2'};
|
||||||
|
final connection = MockXhrClientConnection();
|
||||||
|
|
||||||
|
connection.makeRequest('path', Duration(seconds: 10), metadata,
|
||||||
|
(error) => fail(error.toString()),
|
||||||
|
callOptions: WebCallOptions(withCredentials: true));
|
||||||
|
|
||||||
|
expect(metadata, {
|
||||||
|
'header_1': 'value_1',
|
||||||
|
'header_2': 'value_2',
|
||||||
|
'Content-Type': 'application/grpc-web+proto',
|
||||||
|
'X-User-Agent': 'grpc-web-dart/0.1',
|
||||||
|
'X-Grpc-Web': '1'
|
||||||
|
});
|
||||||
|
verify(connection.latestRequest
|
||||||
|
.setRequestHeader('Content-Type', 'application/grpc-web+proto'));
|
||||||
|
verify(connection.latestRequest
|
||||||
|
.setRequestHeader('X-User-Agent', 'grpc-web-dart/0.1'));
|
||||||
|
verify(connection.latestRequest.setRequestHeader('X-Grpc-Web', '1'));
|
||||||
|
verify(connection.latestRequest.open('POST', 'test:path'));
|
||||||
|
verify(connection.latestRequest.withCredentials = true);
|
||||||
|
verify(connection.latestRequest
|
||||||
|
.overrideMimeType('text/plain; charset=x-user-defined'));
|
||||||
|
verify(connection.latestRequest.responseType = 'text');
|
||||||
|
});
|
||||||
|
|
||||||
test('Sent data converted to stream properly', () async {
|
test('Sent data converted to stream properly', () async {
|
||||||
final metadata = <String, String>{
|
final metadata = <String, String>{
|
||||||
'parameter_1': 'value_1',
|
'parameter_1': 'value_1',
|
||||||
|
|
Loading…
Reference in New Issue