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:
Nic Hite 2020-09-24 21:52:35 -07:00 committed by GitHub
parent dd34af2de4
commit bb4eab0f1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 332 additions and 9 deletions

View File

@ -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

View File

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

View File

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

View File

@ -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.
///

View File

@ -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);

View File

@ -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();
}
}

View File

@ -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();

View File

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

View File

@ -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

View File

@ -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',