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
|
||||
|
||||
* Revert [PR #287](https://github.com/grpc/grpc-dart/pull/287), which allowed
|
||||
|
|
|
@ -16,7 +16,8 @@
|
|||
export 'src/auth/auth.dart'
|
||||
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;
|
||||
|
||||
|
|
|
@ -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.
|
||||
class ClientCall<Q, R> implements Response {
|
||||
final ClientMethod<Q, R> _method;
|
||||
|
|
|
@ -45,7 +45,8 @@ abstract class ClientConnection {
|
|||
|
||||
/// Start a request for [path] with [metadata].
|
||||
GrpcTransportStream makeRequest(String path, Duration timeout,
|
||||
Map<String, String> metadata, ErrorHandler onRequestFailure);
|
||||
Map<String, String> metadata, ErrorHandler onRequestFailure,
|
||||
{CallOptions callOptions});
|
||||
|
||||
/// Shuts down this connection.
|
||||
///
|
||||
|
|
|
@ -178,7 +178,8 @@ class Http2ClientConnection implements connection.ClientConnection {
|
|||
}
|
||||
|
||||
GrpcTransportStream makeRequest(String path, Duration timeout,
|
||||
Map<String, String> metadata, ErrorHandler onRequestFailure) {
|
||||
Map<String, String> metadata, ErrorHandler onRequestFailure,
|
||||
{CallOptions callOptions}) {
|
||||
final headers = createCallHeaders(
|
||||
credentials.isSecure, authority, path, timeout, metadata,
|
||||
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/status.dart';
|
||||
import '../connection.dart';
|
||||
import 'cors.dart' as cors;
|
||||
import 'transport.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 {
|
||||
final HttpRequest _request;
|
||||
final ErrorHandler _onError;
|
||||
|
@ -96,8 +106,12 @@ class XhrTransportStream implements GrpcTransportStream {
|
|||
onError: _onError, onDone: _incomingMessages.close);
|
||||
}
|
||||
|
||||
bool _checkContentType(String contentType) {
|
||||
return _validContentTypePrefix.any(contentType.startsWith);
|
||||
}
|
||||
|
||||
_onHeadersReceived() {
|
||||
final contentType = _request.getResponseHeader('Content-Type');
|
||||
final contentType = _request.getResponseHeader(_contentTypeKey);
|
||||
if (_request.status != 200) {
|
||||
_onError(
|
||||
GrpcError.unavailable('XhrConnection status ${_request.status}'));
|
||||
|
@ -107,7 +121,7 @@ class XhrTransportStream implements GrpcTransportStream {
|
|||
_onError(GrpcError.unavailable('XhrConnection missing Content-Type'));
|
||||
return;
|
||||
}
|
||||
if (!contentType.startsWith('application/grpc')) {
|
||||
if (!_checkContentType(contentType)) {
|
||||
_onError(
|
||||
GrpcError.unavailable('XhrConnection bad Content-Type $contentType'));
|
||||
return;
|
||||
|
@ -162,10 +176,27 @@ class XhrClientConnection extends ClientConnection {
|
|||
|
||||
@override
|
||||
GrpcTransportStream makeRequest(String path, Duration timeout,
|
||||
Map<String, String> metadata, ErrorHandler onError) {
|
||||
final HttpRequest request = createHttpRequest();
|
||||
request.open('POST', uri.resolve(path).toString());
|
||||
Map<String, String> metadata, ErrorHandler onError,
|
||||
{CallOptions callOptions}) {
|
||||
// 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);
|
||||
|
||||
final XhrTransportStream transportStream =
|
||||
|
@ -193,3 +224,12 @@ class XhrClientConnection extends ClientConnection {
|
|||
@override
|
||||
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
|
||||
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>
|
||||
homepage: https://github.com/dart-lang/grpc-dart
|
||||
|
|
|
@ -18,6 +18,7 @@ import 'dart:async';
|
|||
|
||||
import 'dart:html';
|
||||
|
||||
import 'package:grpc/src/client/call.dart';
|
||||
import 'package:grpc/src/client/transport/xhr_transport.dart';
|
||||
import 'package:grpc/src/shared/message.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
@ -80,6 +81,98 @@ void main() {
|
|||
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 {
|
||||
final metadata = <String, String>{
|
||||
'parameter_1': 'value_1',
|
||||
|
|
Loading…
Reference in New Issue