From a68b67ae9750de416b64aab480ad3b7e282e94b6 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Wed, 15 Jun 2022 11:35:34 -0800 Subject: [PATCH 01/33] chore!(api): migrate API category type definitions (#1640) --- .../src/category/amplify_api_category.dart | 134 +++++---- .../lib/src/category/amplify_categories.dart | 1 + .../plugin/amplify_api_plugin_interface.dart | 74 +++-- .../lib/src/types/api/api_types.dart | 2 +- .../types/api/exceptions/api_exception.dart | 9 - .../types/api/graphql/graphql_operation.dart | 15 +- .../lib/src/types/api/rest/http_payload.dart | 82 ++++++ .../src/types/api/rest/rest_exception.dart | 14 +- .../src/types/api/rest/rest_operation.dart | 20 +- .../lib/src/types/api/rest/rest_response.dart | 59 ---- packages/api/amplify_api/.gitignore | 40 ++- .../example/lib/graphql_api_view.dart | 3 +- .../api/amplify_api/example/lib/main.dart | 2 +- .../example/lib/rest_api_view.dart | 65 ++--- packages/api/amplify_api/example/pubspec.yaml | 1 + .../lib/src/method_channel_api.dart | 229 +++++++++++---- packages/api/amplify_api/pubspec.yaml | 3 +- .../test/amplify_rest_api_methods_test.dart | 270 ++++++++---------- .../example/lib/main.dart | 13 +- 19 files changed, 611 insertions(+), 425 deletions(-) create mode 100644 packages/amplify_core/lib/src/types/api/rest/http_payload.dart delete mode 100644 packages/amplify_core/lib/src/types/api/rest/rest_response.dart diff --git a/packages/amplify_core/lib/src/category/amplify_api_category.dart b/packages/amplify_core/lib/src/category/amplify_api_category.dart index 99406d31fc..7d9692a725 100644 --- a/packages/amplify_core/lib/src/category/amplify_api_category.dart +++ b/packages/amplify_core/lib/src/category/amplify_api_category.dart @@ -1,5 +1,5 @@ /* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. @@ -21,17 +21,13 @@ class APICategory extends AmplifyCategory<APIPluginInterface> { Category get category => Category.api; // ====== GraphQL ======= - GraphQLOperation<T> query<T>({required GraphQLRequest<T> request}) { - return plugins.length == 1 - ? plugins[0].query(request: request) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<GraphQLResponse<T>> query<T>( + {required GraphQLRequest<T> request}) => + defaultPlugin.query(request: request); - GraphQLOperation<T> mutate<T>({required GraphQLRequest<T> request}) { - return plugins.length == 1 - ? plugins[0].mutate(request: request) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<GraphQLResponse<T>> mutate<T>( + {required GraphQLRequest<T> request}) => + defaultPlugin.mutate(request: request); /// Subscribes to the given [request] and returns the stream of response events. /// An optional [onEstablished] callback can be used to be alerted when the @@ -42,52 +38,88 @@ class APICategory extends AmplifyCategory<APIPluginInterface> { Stream<GraphQLResponse<T>> subscribe<T>( GraphQLRequest<T> request, { void Function()? onEstablished, - }) { - return plugins.length == 1 - ? plugins[0].subscribe(request, onEstablished: onEstablished) - : throw _pluginNotAddedException('Api'); - } + }) => + defaultPlugin.subscribe(request, onEstablished: onEstablished); // ====== RestAPI ====== - void cancelRequest(String cancelToken) { - return plugins.length == 1 - ? plugins[0].cancelRequest(cancelToken) - : throw _pluginNotAddedException('Api'); - } - RestOperation get({required RestOptions restOptions}) { - return plugins.length == 1 - ? plugins[0].get(restOptions: restOptions) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<AWSStreamedHttpResponse> delete( + String path, { + Map<String, String>? headers, + HttpPayload? body, + Map<String, String>? queryParameters, + String? apiName, + }) => + defaultPlugin.delete( + path, + headers: headers, + body: body, + apiName: apiName, + ); - RestOperation put({required RestOptions restOptions}) { - return plugins.length == 1 - ? plugins[0].put(restOptions: restOptions) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<AWSStreamedHttpResponse> get( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) => + defaultPlugin.get( + path, + headers: headers, + apiName: apiName, + ); - RestOperation post({required RestOptions restOptions}) { - return plugins.length == 1 - ? plugins[0].post(restOptions: restOptions) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<AWSStreamedHttpResponse> head( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) => + defaultPlugin.head( + path, + headers: headers, + apiName: apiName, + ); - RestOperation delete({required RestOptions restOptions}) { - return plugins.length == 1 - ? plugins[0].delete(restOptions: restOptions) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<AWSStreamedHttpResponse> patch( + String path, { + Map<String, String>? headers, + HttpPayload? body, + Map<String, String>? queryParameters, + String? apiName, + }) => + defaultPlugin.patch( + path, + headers: headers, + body: body, + apiName: apiName, + ); - RestOperation head({required RestOptions restOptions}) { - return plugins.length == 1 - ? plugins[0].head(restOptions: restOptions) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<AWSStreamedHttpResponse> post( + String path, { + Map<String, String>? headers, + HttpPayload? body, + Map<String, String>? queryParameters, + String? apiName, + }) => + defaultPlugin.post( + path, + headers: headers, + body: body, + apiName: apiName, + ); - RestOperation patch({required RestOptions restOptions}) { - return plugins.length == 1 - ? plugins[0].patch(restOptions: restOptions) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<AWSStreamedHttpResponse> put( + String path, { + Map<String, String>? headers, + HttpPayload? body, + Map<String, String>? queryParameters, + String? apiName, + }) => + defaultPlugin.put( + path, + headers: headers, + body: body, + apiName: apiName, + ); } diff --git a/packages/amplify_core/lib/src/category/amplify_categories.dart b/packages/amplify_core/lib/src/category/amplify_categories.dart index 969ea3ebc7..4c014d05dc 100644 --- a/packages/amplify_core/lib/src/category/amplify_categories.dart +++ b/packages/amplify_core/lib/src/category/amplify_categories.dart @@ -18,6 +18,7 @@ library amplify_interface; import 'dart:async'; import 'package:amplify_core/amplify_core.dart'; +import 'package:async/async.dart'; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; diff --git a/packages/amplify_core/lib/src/plugin/amplify_api_plugin_interface.dart b/packages/amplify_core/lib/src/plugin/amplify_api_plugin_interface.dart index d318db6e13..5169acb091 100644 --- a/packages/amplify_core/lib/src/plugin/amplify_api_plugin_interface.dart +++ b/packages/amplify_core/lib/src/plugin/amplify_api_plugin_interface.dart @@ -1,5 +1,5 @@ /* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. @@ -14,6 +14,7 @@ */ import 'package:amplify_core/amplify_core.dart'; +import 'package:async/async.dart'; import 'package:meta/meta.dart'; abstract class APIPluginInterface extends AmplifyPluginInterface { @@ -25,11 +26,13 @@ abstract class APIPluginInterface extends AmplifyPluginInterface { ModelProviderInterface? get modelProvider => throw UnimplementedError(); // ====== GraphQL ======= - GraphQLOperation<T> query<T>({required GraphQLRequest<T> request}) { + CancelableOperation<GraphQLResponse<T>> query<T>( + {required GraphQLRequest<T> request}) { throw UnimplementedError('query() has not been implemented.'); } - GraphQLOperation<T> mutate<T>({required GraphQLRequest<T> request}) { + CancelableOperation<GraphQLResponse<T>> mutate<T>( + {required GraphQLRequest<T> request}) { throw UnimplementedError('mutate() has not been implemented.'); } @@ -50,31 +53,64 @@ abstract class APIPluginInterface extends AmplifyPluginInterface { void registerAuthProvider(APIAuthProvider authProvider); // ====== RestAPI ====== - void cancelRequest(String cancelToken) { - throw UnimplementedError('cancelRequest has not been implemented.'); - } - - RestOperation get({required RestOptions restOptions}) { - throw UnimplementedError('get has not been implemented.'); + CancelableOperation<AWSStreamedHttpResponse> delete( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + throw UnimplementedError('delete() has not been implemented'); } - RestOperation put({required RestOptions restOptions}) { - throw UnimplementedError('put has not been implemented.'); + /// Uses Amplify configuration to authorize request to [path] and returns + /// [CancelableOperation] which resolves to standard HTTP + /// [Response](https://pub.dev/documentation/http/latest/http/Response-class.html). + CancelableOperation<AWSStreamedHttpResponse> get( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + throw UnimplementedError('get() has not been implemented'); } - RestOperation post({required RestOptions restOptions}) { - throw UnimplementedError('post has not been implemented.'); + CancelableOperation<AWSStreamedHttpResponse> head( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + throw UnimplementedError('head() has not been implemented'); } - RestOperation delete({required RestOptions restOptions}) { - throw UnimplementedError('delete has not been implemented.'); + CancelableOperation<AWSStreamedHttpResponse> patch( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + throw UnimplementedError('patch() has not been implemented'); } - RestOperation head({required RestOptions restOptions}) { - throw UnimplementedError('head has not been implemented.'); + CancelableOperation<AWSStreamedHttpResponse> post( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + throw UnimplementedError('post() has not been implemented'); } - RestOperation patch({required RestOptions restOptions}) { - throw UnimplementedError('patch has not been implemented.'); + CancelableOperation<AWSStreamedHttpResponse> put( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + throw UnimplementedError('put() has not been implemented'); } } diff --git a/packages/amplify_core/lib/src/types/api/api_types.dart b/packages/amplify_core/lib/src/types/api/api_types.dart index 3e69a1dc4b..299fd03412 100644 --- a/packages/amplify_core/lib/src/types/api/api_types.dart +++ b/packages/amplify_core/lib/src/types/api/api_types.dart @@ -27,10 +27,10 @@ export 'graphql/graphql_response.dart'; export 'graphql/graphql_response_error.dart'; export 'graphql/graphql_subscription_operation.dart'; +export 'rest/http_payload.dart'; export 'rest/rest_exception.dart'; export 'rest/rest_operation.dart'; export 'rest/rest_options.dart'; -export 'rest/rest_response.dart'; export 'types/pagination/paginated_model_type.dart'; export 'types/pagination/paginated_result.dart'; diff --git a/packages/amplify_core/lib/src/types/api/exceptions/api_exception.dart b/packages/amplify_core/lib/src/types/api/exceptions/api_exception.dart index 9f9d833110..2ec1bf37ac 100644 --- a/packages/amplify_core/lib/src/types/api/exceptions/api_exception.dart +++ b/packages/amplify_core/lib/src/types/api/exceptions/api_exception.dart @@ -19,18 +19,11 @@ import 'package:amplify_core/amplify_core.dart'; /// Exception thrown from the API Category. /// {@endtemplate} class ApiException extends AmplifyException { - /// HTTP status of response, only available if error - @Deprecated( - 'Use RestException instead to retrieve the HTTP response. Existing uses of ' - 'ApiException for handling REST errors can be safely replaced with RestException') - final int? httpStatusCode; - /// {@macro api_exception} const ApiException( String message, { String? recoverySuggestion, String? underlyingException, - this.httpStatusCode, }) : super( message, recoverySuggestion: recoverySuggestion, @@ -40,7 +33,6 @@ class ApiException extends AmplifyException { /// Constructor for down casting an AmplifyException to this exception ApiException._private( AmplifyException exception, - this.httpStatusCode, ) : super( exception.message, recoverySuggestion: exception.recoverySuggestion, @@ -57,7 +49,6 @@ class ApiException extends AmplifyException { } return ApiException._private( AmplifyException.fromMap(serializedException), - statusCode, ); } } diff --git a/packages/amplify_core/lib/src/types/api/graphql/graphql_operation.dart b/packages/amplify_core/lib/src/types/api/graphql/graphql_operation.dart index 94035a8997..b9f72dbd37 100644 --- a/packages/amplify_core/lib/src/types/api/graphql/graphql_operation.dart +++ b/packages/amplify_core/lib/src/types/api/graphql/graphql_operation.dart @@ -1,5 +1,5 @@ /* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. @@ -13,11 +13,14 @@ * permissions and limitations under the License. */ -import 'package:amplify_core/amplify_core.dart'; +import 'package:async/async.dart'; -class GraphQLOperation<T> { - final Function cancel; - final Future<GraphQLResponse<T>> response; +import 'graphql_response.dart'; - const GraphQLOperation({required this.response, required this.cancel}); +/// Allows callers to synchronously get the unstreamed response with decoded body. +extension GraphQLOperation<T> on CancelableOperation<GraphQLResponse<T>> { + @Deprecated('use .value instead') + Future<GraphQLResponse<T>> get response { + return value; + } } diff --git a/packages/amplify_core/lib/src/types/api/rest/http_payload.dart b/packages/amplify_core/lib/src/types/api/rest/http_payload.dart new file mode 100644 index 0000000000..eb657d7543 --- /dev/null +++ b/packages/amplify_core/lib/src/types/api/rest/http_payload.dart @@ -0,0 +1,82 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ +import 'dart:async'; +import 'dart:convert'; + +import 'package:async/async.dart'; + +/// {@template amplify_core.http_payload} +/// An HTTP request's payload. +/// {@endtemplate} +class HttpPayload extends StreamView<List<int>> { + String contentType = 'text/plain'; + + /// {@macro amplify_core.http_payload} + factory HttpPayload([Object? body]) { + if (body == null) { + return HttpPayload.empty(); + } + if (body is String) { + return HttpPayload.string(body); + } + if (body is List<int>) { + return HttpPayload.bytes(body); + } + if (body is Stream<List<int>>) { + return HttpPayload.streaming(body); + } + if (body is Map<String, String>) { + return HttpPayload.formFields(body); + } + throw ArgumentError('Invalid HTTP payload type: ${body.runtimeType}'); + } + + /// An empty HTTP body. + HttpPayload.empty() : super(const Stream.empty()); + + /// A UTF-8 encoded HTTP body. + HttpPayload.string(String body, {Encoding encoding = utf8}) + : super(LazyStream(() => Stream.value(encoding.encode(body)))); + + /// A byte buffer HTTP body. + HttpPayload.bytes(List<int> body) : super(Stream.value(body)); + + /// A form-encoded body of `key=value` pairs. + HttpPayload.formFields(Map<String, String> body, {Encoding encoding = utf8}) + : contentType = 'application/x-www-form-urlencoded', + super(LazyStream(() => Stream.value( + encoding.encode(_mapToQuery(body, encoding: encoding))))); + + /// Encodes body as a JSON string and sets Content-Type to 'application/json' + HttpPayload.json(Object body, {Encoding encoding = utf8}) + : contentType = 'application/json', + super( + LazyStream(() => Stream.value(encoding.encode(json.encode(body))))); + + /// A streaming HTTP body. + HttpPayload.streaming(Stream<List<int>> body) : super(body); +} + +/// Converts a [Map] from parameter names to values to a URL query string. +/// +/// _mapToQuery({"foo": "bar", "baz": "bang"}); +/// //=> "foo=bar&baz=bang" +/// +/// Similar util at https://github.com/dart-lang/http/blob/06649afbb5847dbb0293816ba8348766b116e419/pkgs/http/lib/src/utils.dart#L15 +String _mapToQuery(Map<String, String> map, {required Encoding encoding}) => map + .entries + .map((e) => + '${Uri.encodeQueryComponent(e.key, encoding: encoding)}=${Uri.encodeQueryComponent(e.value, encoding: encoding)}') + .join('&'); diff --git a/packages/amplify_core/lib/src/types/api/rest/rest_exception.dart b/packages/amplify_core/lib/src/types/api/rest/rest_exception.dart index fe6a6a8ee5..1f6dc18c2e 100644 --- a/packages/amplify_core/lib/src/types/api/rest/rest_exception.dart +++ b/packages/amplify_core/lib/src/types/api/rest/rest_exception.dart @@ -19,16 +19,8 @@ import 'package:amplify_core/amplify_core.dart'; /// An HTTP error encountered during a REST API call, i.e. for calls returning /// non-2xx status codes. /// {@endtemplate} -class RestException extends ApiException { - /// The HTTP response from the server. - final RestResponse response; - +@Deprecated('BREAKING CHANGE: No longer thrown for non-200 responses.') +abstract class RestException extends ApiException { /// {@macro rest_exception} - RestException(this.response) - : super(response.body, httpStatusCode: response.statusCode); - - @override - String toString() { - return 'RestException{response=$response}'; - } + const RestException() : super('REST exception.'); } diff --git a/packages/amplify_core/lib/src/types/api/rest/rest_operation.dart b/packages/amplify_core/lib/src/types/api/rest/rest_operation.dart index eb84a0ea42..a24ad39ad2 100644 --- a/packages/amplify_core/lib/src/types/api/rest/rest_operation.dart +++ b/packages/amplify_core/lib/src/types/api/rest/rest_operation.dart @@ -1,5 +1,5 @@ /* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. @@ -13,11 +13,17 @@ * permissions and limitations under the License. */ -import 'rest_response.dart'; +import 'package:async/async.dart'; +import 'package:aws_common/aws_common.dart'; -class RestOperation { - final Function cancel; - final Future<RestResponse> response; - - const RestOperation({required this.response, required this.cancel}); +/// Allows callers to synchronously get unstreamed response with the decoded body. +extension RestOperation on CancelableOperation<AWSStreamedHttpResponse> { + Future<AWSHttpResponse> get response async { + final value = await this.value; + return AWSHttpResponse( + body: await value.bodyBytes, + statusCode: value.statusCode, + headers: value.headers, + ); + } } diff --git a/packages/amplify_core/lib/src/types/api/rest/rest_response.dart b/packages/amplify_core/lib/src/types/api/rest/rest_response.dart deleted file mode 100644 index f93a2079e4..0000000000 --- a/packages/amplify_core/lib/src/types/api/rest/rest_response.dart +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. 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. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file 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. - */ - -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:amplify_core/amplify_core.dart'; -import 'package:meta/meta.dart'; - -/// {@template rest_response} -/// An HTTP response from a REST API call. -/// {@endtemplate} -@immutable -class RestResponse with AWSEquatable<RestResponse> { - /// The response status code. - final int statusCode; - - /// The response headers. - /// - /// Will be `null` if unavailable from the platform. - final Map<String, String>? headers; - - /// The response body bytes. - final Uint8List data; - - /// The decoded body (using UTF-8). - /// - /// For custom processing, use [data] to get the raw body bytes. - late final String body; - - /// {@macro rest_response} - RestResponse({ - required Uint8List? data, - required this.headers, - required this.statusCode, - }) : data = data ?? Uint8List(0) { - body = utf8.decode(this.data, allowMalformed: true); - } - - @override - List<Object?> get props => [statusCode, headers, data]; - - @override - String toString() { - return 'RestResponse{statusCode=$statusCode, headers=$headers, body=$body}'; - } -} diff --git a/packages/api/amplify_api/.gitignore b/packages/api/amplify_api/.gitignore index e9dc58d3d6..6bb69a50e0 100644 --- a/packages/api/amplify_api/.gitignore +++ b/packages/api/amplify_api/.gitignore @@ -1,7 +1,43 @@ +# See https://dart.dev/guides/libraries/private-files + +# Miscellaneous +*.class +*.log +*.pyc +*.swp .DS_Store -.dart_tool/ +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies .packages +.pub-cache/ .pub/ - build/ + +# Code coverage +coverage/ + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/packages/api/amplify_api/example/lib/graphql_api_view.dart b/packages/api/amplify_api/example/lib/graphql_api_view.dart index 53a218efcd..6644dad380 100644 --- a/packages/api/amplify_api/example/lib/graphql_api_view.dart +++ b/packages/api/amplify_api/example/lib/graphql_api_view.dart @@ -14,6 +14,7 @@ */ import 'package:amplify_flutter/amplify_flutter.dart'; +import 'package:async/async.dart'; import 'package:flutter/material.dart'; class GraphQLApiView extends StatefulWidget { @@ -29,7 +30,7 @@ class GraphQLApiView extends StatefulWidget { class _GraphQLApiViewState extends State<GraphQLApiView> { String _result = ''; void Function()? _unsubscribe; - late GraphQLOperation _lastOperation; + late CancelableOperation _lastOperation; Future<void> subscribe() async { String graphQLDocument = '''subscription MySubscription { diff --git a/packages/api/amplify_api/example/lib/main.dart b/packages/api/amplify_api/example/lib/main.dart index 5c044e7aec..6e5dbf862d 100644 --- a/packages/api/amplify_api/example/lib/main.dart +++ b/packages/api/amplify_api/example/lib/main.dart @@ -44,7 +44,7 @@ class _MyAppState extends State<MyApp> { } void _configureAmplify() async { - Amplify.addPlugins([AmplifyAuthCognito(), AmplifyAPI()]); + await Amplify.addPlugins([AmplifyAuthCognito(), AmplifyAPI()]); try { await Amplify.configure(amplifyconfig); diff --git a/packages/api/amplify_api/example/lib/rest_api_view.dart b/packages/api/amplify_api/example/lib/rest_api_view.dart index aeca89c97f..68f8a414f1 100644 --- a/packages/api/amplify_api/example/lib/rest_api_view.dart +++ b/packages/api/amplify_api/example/lib/rest_api_view.dart @@ -13,9 +13,8 @@ * permissions and limitations under the License. */ -import 'dart:convert'; - import 'package:amplify_flutter/amplify_flutter.dart'; +import 'package:async/async.dart'; import 'package:flutter/material.dart'; class RestApiView extends StatefulWidget { @@ -27,7 +26,7 @@ class RestApiView extends StatefulWidget { class _RestApiViewState extends State<RestApiView> { late TextEditingController _apiPathController; - late RestOperation _lastRestOperation; + late CancelableOperation _lastRestOperation; @override void initState() { @@ -39,18 +38,16 @@ class _RestApiViewState extends State<RestApiView> { void onPutPressed() async { try { - RestOperation restOperation = Amplify.API.put( - restOptions: RestOptions( - path: _apiPathController.text, - body: ascii.encode('{"name":"Mow the lawn"}'), - ), + final restOperation = Amplify.API.put( + _apiPathController.text, + body: HttpPayload.json({'name': 'Mow the lawn'}), ); _lastRestOperation = restOperation; - RestResponse response = await restOperation.response; + final response = await restOperation.response; print('Put SUCCESS'); - print(response); + print(response.decodeBody()); } on Exception catch (e) { print('Put FAILED'); print(e); @@ -59,18 +56,16 @@ class _RestApiViewState extends State<RestApiView> { void onPostPressed() async { try { - RestOperation restOperation = Amplify.API.post( - restOptions: RestOptions( - path: _apiPathController.text, - body: ascii.encode('{"name":"Mow the lawn"}'), - ), + final restOperation = Amplify.API.post( + _apiPathController.text, + body: HttpPayload.json({'name': 'Mow the lawn'}), ); _lastRestOperation = restOperation; - RestResponse response = await restOperation.response; + final response = await restOperation.response; print('Post SUCCESS'); - print(response); + print(response.decodeBody()); } on Exception catch (e) { print('Post FAILED'); print(e); @@ -79,16 +74,15 @@ class _RestApiViewState extends State<RestApiView> { void onGetPressed() async { try { - RestOperation restOperation = Amplify.API.get( - restOptions: RestOptions( - path: _apiPathController.text, - )); + final restOperation = Amplify.API.get( + _apiPathController.text, + ); _lastRestOperation = restOperation; - RestResponse response = await restOperation.response; + final response = await restOperation.response; print('Get SUCCESS'); - print(response); + print(response.decodeBody()); } on ApiException catch (e) { print('Get FAILED'); print(e.toString()); @@ -97,15 +91,14 @@ class _RestApiViewState extends State<RestApiView> { void onDeletePressed() async { try { - RestOperation restOperation = Amplify.API.delete( - restOptions: RestOptions(path: _apiPathController.text), + final restOperation = Amplify.API.delete( + _apiPathController.text, ); - _lastRestOperation = restOperation; - RestResponse response = await restOperation.response; + final response = await restOperation.response; print('Delete SUCCESS'); - print(response); + print(response.decodeBody()); } on Exception catch (e) { print('Delete FAILED'); print(e); @@ -123,15 +116,14 @@ class _RestApiViewState extends State<RestApiView> { void onHeadPressed() async { try { - RestOperation restOperation = Amplify.API.head( - restOptions: RestOptions(path: _apiPathController.text), + final restOperation = Amplify.API.head( + _apiPathController.text, ); _lastRestOperation = restOperation; - RestResponse response = await restOperation.response; + await restOperation.response; print('Head SUCCESS'); - print(response); } on ApiException catch (e) { print('Head FAILED'); print(e.toString()); @@ -140,15 +132,16 @@ class _RestApiViewState extends State<RestApiView> { void onPatchPressed() async { try { - RestOperation restOperation = Amplify.API.patch( - restOptions: RestOptions(path: _apiPathController.text), + final restOperation = Amplify.API.patch( + _apiPathController.text, + body: HttpPayload.json({'name': 'Mow the lawn'}), ); _lastRestOperation = restOperation; - RestResponse response = await restOperation.response; + final response = await restOperation.response; print('Patch SUCCESS'); - print(response); + print(response.decodeBody()); } on ApiException catch (e) { print('Patch FAILED'); print(e.toString()); diff --git a/packages/api/amplify_api/example/pubspec.yaml b/packages/api/amplify_api/example/pubspec.yaml index 4e57ade772..7b017f9370 100644 --- a/packages/api/amplify_api/example/pubspec.yaml +++ b/packages/api/amplify_api/example/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: path: ../../../auth/amplify_auth_cognito amplify_flutter: path: ../../../amplify/amplify_flutter + async: ^2.8.2 aws_common: ^0.1.0 # The following adds the Cupertino Icons font to your application. diff --git a/packages/api/amplify_api/lib/src/method_channel_api.dart b/packages/api/amplify_api/lib/src/method_channel_api.dart index 59deb7fca0..95e8f5c17d 100644 --- a/packages/api/amplify_api/lib/src/method_channel_api.dart +++ b/packages/api/amplify_api/lib/src/method_channel_api.dart @@ -14,13 +14,14 @@ */ import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import 'package:amplify_api/src/graphql/graphql_response_decoder.dart'; import 'package:amplify_api/src/graphql/graphql_subscription_event.dart'; import 'package:amplify_api/src/graphql/graphql_subscription_transformer.dart'; import 'package:amplify_core/amplify_core.dart'; - +import 'package:async/async.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -150,31 +151,19 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { } @override - GraphQLOperation<T> query<T>({required GraphQLRequest<T> request}) { - Future<GraphQLResponse<T>> response = + CancelableOperation<GraphQLResponse<T>> query<T>( + {required GraphQLRequest<T> request}) { + Future<GraphQLResponse<T>> responseFuture = _getMethodChannelResponse(methodName: 'query', request: request); - - //TODO: Cancel implementation will be added along with REST API as it is shared - GraphQLOperation<T> result = GraphQLOperation<T>( - cancel: () => cancelRequest(request.id), - response: response, - ); - - return result; + return CancelableOperation.fromFuture(responseFuture); } @override - GraphQLOperation<T> mutate<T>({required GraphQLRequest<T> request}) { - Future<GraphQLResponse<T>> response = + CancelableOperation<GraphQLResponse<T>> mutate<T>( + {required GraphQLRequest<T> request}) { + Future<GraphQLResponse<T>> responseFuture = _getMethodChannelResponse(methodName: 'mutate', request: request); - - //TODO: Cancel implementation will be added along with REST API as it is shared - GraphQLOperation<T> result = GraphQLOperation<T>( - cancel: () => cancelRequest(request.id), - response: response, - ); - - return result; + return CancelableOperation.fromFuture(responseFuture); } @override @@ -248,21 +237,73 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { } // ====== RestAPI ====== - RestOperation _restFunctionHelper( - {required String methodName, required RestOptions restOptions}) { - // Send Request cancelToken to Native - String cancelToken = UUID.getUUID(); - Future<RestResponse> futureResponse = - _callNativeRestMethod(methodName, cancelToken, restOptions); + Future<AWSStreamedHttpResponse> _restResponseHelper({ + required String methodName, + required String path, + required String cancelToken, + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) async { + Uint8List? bodyBytes; + if (body != null) { + final completer = Completer<Uint8List>(); + final sink = ByteConversionSink.withCallback( + (bytes) => completer.complete(Uint8List.fromList(bytes)), + ); + body.listen( + sink.add, + onError: completer.completeError, + onDone: sink.close, + cancelOnError: true, + ); + bodyBytes = await completer.future; + } - return RestOperation( - response: futureResponse, - cancel: () => cancelRequest(cancelToken), + final restOptions = RestOptions( + path: path, + body: bodyBytes, + apiName: apiName, + queryParameters: queryParameters, + headers: headers, + ); + return _callNativeRestMethod(methodName, cancelToken, restOptions); + } + + CancelableOperation<AWSStreamedHttpResponse> _restFunctionHelper({ + required String methodName, + required String path, + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + // Send Request cancelToken to Native + String cancelToken = uuid(); + // Ensure Content-Type header matches payload. + var modifiedHeaders = headers != null ? Map.of(headers) : null; + final contentType = body?.contentType; + if (contentType != null) { + modifiedHeaders = modifiedHeaders ?? {}; + modifiedHeaders.putIfAbsent(AWSHeaders.contentType, () => contentType); + } + final responseFuture = _restResponseHelper( + methodName: methodName, + path: path, + cancelToken: cancelToken, + body: body, + headers: modifiedHeaders, + queryParameters: queryParameters, + apiName: apiName, ); + + return CancelableOperation.fromFuture(responseFuture, + onCancel: () => cancelRequest(cancelToken)); } - Future<RestResponse> _callNativeRestMethod( + Future<AWSStreamedHttpResponse> _callNativeRestMethod( String methodName, String cancelToken, RestOptions restOptions) async { // Prepare map input Map<String, dynamic> inputsMap = <String, dynamic>{}; @@ -284,55 +325,125 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { } } - bool _shouldThrow(int statusCode) { - return statusCode < 200 || statusCode > 299; - } - - RestResponse _formatRestResponse(Map<String, dynamic> res) { + AWSStreamedHttpResponse _formatRestResponse(Map<String, dynamic> res) { final statusCode = res['statusCode'] as int; - final headers = res['headers'] as Map?; - final response = RestResponse( - data: res['data'] as Uint8List?, - headers: headers?.cast<String, String>(), - statusCode: statusCode, - ); - if (_shouldThrow(statusCode)) { - throw RestException(response); - } - return response; + // Make type-safe version of response headers. + final serializedHeaders = res['headers'] as Map?; + final headers = serializedHeaders?.cast<String, String>(); + final rawResponseBody = res['data'] as Uint8List?; + + return AWSStreamedHttpResponse( + statusCode: statusCode, + headers: headers, + body: Stream.value(rawResponseBody ?? [])); } @override - RestOperation get({required RestOptions restOptions}) { - return _restFunctionHelper(methodName: 'get', restOptions: restOptions); + CancelableOperation<AWSStreamedHttpResponse> get( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + return _restFunctionHelper( + methodName: 'get', + path: path, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ); } @override - RestOperation put({required RestOptions restOptions}) { - return _restFunctionHelper(methodName: 'put', restOptions: restOptions); + CancelableOperation<AWSStreamedHttpResponse> put( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + return _restFunctionHelper( + methodName: 'put', + path: path, + body: body, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ); } @override - RestOperation post({required RestOptions restOptions}) { - return _restFunctionHelper(methodName: 'post', restOptions: restOptions); + CancelableOperation<AWSStreamedHttpResponse> post( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + return _restFunctionHelper( + methodName: 'post', + path: path, + body: body, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ); } @override - RestOperation delete({required RestOptions restOptions}) { - return _restFunctionHelper(methodName: 'delete', restOptions: restOptions); + CancelableOperation<AWSStreamedHttpResponse> delete( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + return _restFunctionHelper( + methodName: 'delete', + path: path, + body: body, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ); } @override - RestOperation head({required RestOptions restOptions}) { - return _restFunctionHelper(methodName: 'head', restOptions: restOptions); + CancelableOperation<AWSStreamedHttpResponse> head( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + return _restFunctionHelper( + methodName: 'head', + path: path, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ); } @override - RestOperation patch({required RestOptions restOptions}) { - return _restFunctionHelper(methodName: 'patch', restOptions: restOptions); + CancelableOperation<AWSStreamedHttpResponse> patch( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + return _restFunctionHelper( + methodName: 'patch', + path: path, + body: body, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ); } - @override + /// Cancels a request with a given request ID. + @Deprecated('Use .cancel() on CancelableOperation instead.') Future<void> cancelRequest(String cancelToken) async { print('Attempting to cancel Operation $cancelToken'); diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index 4305f09b30..869779172e 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -2,6 +2,7 @@ name: amplify_api description: The Amplify Flutter API category plugin, supporting GraphQL and REST operations. version: 0.5.0 homepage: https://github.com/aws-amplify/amplify-flutter/tree/main/packages/amplify_api +publish_to: none # until finalized environment: sdk: ">=2.17.0 <3.0.0" @@ -12,6 +13,7 @@ dependencies: amplify_api_ios: 0.5.0 amplify_core: 0.5.0 amplify_flutter: 0.5.0 + async: ^2.8.2 aws_common: ^0.1.0 collection: ^1.15.0 flutter: @@ -23,7 +25,6 @@ dev_dependencies: amplify_lints: ^2.0.0 amplify_test: path: ../../amplify_test - async: ^2.6.0 build_runner: ^2.0.0 flutter_test: sdk: flutter diff --git a/packages/api/amplify_api/test/amplify_rest_api_methods_test.dart b/packages/api/amplify_api/test/amplify_rest_api_methods_test.dart index 925c940b6b..5106ada1c2 100644 --- a/packages/api/amplify_api/test/amplify_rest_api_methods_test.dart +++ b/packages/api/amplify_api/test/amplify_rest_api_methods_test.dart @@ -26,9 +26,19 @@ import 'graphql_helpers_test.dart'; const statusOK = 200; const statusBadRequest = 400; - -// Matchers -final throwsRestException = throwsA(isA<RestException>()); +const mowLawnBody = '{"name": "Mow the lawn"}'; +const hello = 'Hello from lambda!'; +final helloResponse = ascii.encode(hello); +final encodedMowLoanBody = ascii.encode(mowLawnBody); +const queryParameters = { + 'queryParameterA': 'queryValueA', + 'queryParameterB': 'queryValueB' +}; +const headers = { + 'headerA': 'headerValueA', + 'headerB': 'headerValueB', + AWSHeaders.contentType: 'text/plain' +}; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -42,184 +52,177 @@ void main() { await Amplify.addPlugin(api); }); - test('PUT returns proper response.data', () async { - var responseData = Uint8List.fromList( - '{"success": "put call succeed!","url":/items?queryParameterA=queryValueA&queryParameterB=queryValueB","body": {"name": "Mow the lawn"}}' - .codeUnits); - var body = Uint8List.fromList('{"name":"Mow the lawn"}'.codeUnits); - var queryParameters = { - 'queryParameterA': 'queryValueA', - 'queryParameterB': 'queryValueB' - }; - var headers = {'headerA': 'headerValueA', 'headerB': 'headerValueB'}; + Future<void> _assertResponse(AWSStreamedHttpResponse response) async { + final actualResponseBody = await response.decodeBody(); + expect(actualResponseBody, hello); + expect(response.statusCode, statusOK); + } + test('PUT returns proper response.data', () async { apiChannel.setMockMethodCallHandler((MethodCall methodCall) async { if (methodCall.method == 'put') { Map<dynamic, dynamic> restOptions = methodCall.arguments['restOptions'] as Map; expect(restOptions['apiName'], 'restapi'); expect(restOptions['path'], '/items'); - expect(restOptions['body'], body); + expect(restOptions['body'], encodedMowLoanBody); expect(restOptions['queryParameters'], queryParameters); expect(restOptions['headers'], headers); - - return {'data': responseData, 'statusCode': statusOK}; + return {'data': helloResponse, 'statusCode': statusOK}; } }); - RestOperation restOperation = api.put( - restOptions: RestOptions( - path: '/items', - body: body, - apiName: 'restapi', - queryParameters: queryParameters, - headers: headers, - ), + final restOperation = api.put( + '/items', + body: HttpPayload.string(mowLawnBody), + apiName: 'restapi', + queryParameters: queryParameters, + headers: headers, ); - RestResponse response = await restOperation.response; - - expect(response.data, responseData); + final response = await restOperation.value; + await _assertResponse(response); }); test('POST returns proper response.data', () async { - var responseData = Uint8List.fromList( - '{"success": "post call succeed!","url":"/items?queryParameterA=queryValueA&queryParameterB=queryValueB","body": {"name": "Mow the lawn"}}' - .codeUnits); - var body = Uint8List.fromList('{"name":"Mow the lawn"}'.codeUnits); - var queryParameters = { - 'queryParameterA': 'queryValueA', - 'queryParameterB': 'queryValueB' - }; - var headers = {'headerA': 'headerValueA', 'headerB': 'headerValueB'}; - apiChannel.setMockMethodCallHandler((MethodCall methodCall) async { if (methodCall.method == 'post') { Map<dynamic, dynamic> restOptions = methodCall.arguments['restOptions'] as Map; expect(restOptions['apiName'], 'restapi'); expect(restOptions['path'], '/items'); - expect(restOptions['body'], body); + expect(restOptions['body'], encodedMowLoanBody); expect(restOptions['queryParameters'], queryParameters); expect(restOptions['headers'], headers); - - return {'data': responseData, 'statusCode': statusOK}; + return {'data': helloResponse, 'statusCode': statusOK}; } }); - RestOperation restOperation = api.post( - restOptions: RestOptions( - path: '/items', - body: body, - apiName: 'restapi', - headers: headers, - queryParameters: queryParameters, - ), + final restOperation = api.post( + '/items', + body: HttpPayload.string(mowLawnBody), + apiName: 'restapi', + queryParameters: queryParameters, + headers: headers, ); - RestResponse response = await restOperation.response; - - expect(response.data, responseData); + final response = await restOperation.value; + await _assertResponse(response); }); test('GET returns proper response.data', () async { - var responseData = Uint8List.fromList( - '{"success":"get call succeed!","url":"/items"}'.codeUnits); - apiChannel.setMockMethodCallHandler((MethodCall methodCall) async { if (methodCall.method == 'get') { Map<dynamic, dynamic> restOptions = methodCall.arguments['restOptions'] as Map; + expect(restOptions['apiName'], 'restapi'); expect(restOptions['path'], '/items'); - - return {'data': responseData, 'statusCode': statusOK}; + expect(restOptions['queryParameters'], queryParameters); + expect(restOptions['headers'], headers); + return {'data': helloResponse, 'statusCode': statusOK}; } }); - RestOperation restOperation = api.get( - restOptions: const RestOptions( - path: '/items', - )); - - RestResponse response = await restOperation.response; + final restOperation = api.get( + '/items', + apiName: 'restapi', + queryParameters: queryParameters, + headers: headers, + ); - expect(response.data, responseData); + final response = await restOperation.value; + await _assertResponse(response); }); test('DELETE returns proper response.data', () async { - var responseData = Uint8List.fromList( - '{"success":"delete call succeed!","url":"/items"}'.codeUnits); - apiChannel.setMockMethodCallHandler((MethodCall methodCall) async { if (methodCall.method == 'delete') { Map<dynamic, dynamic> restOptions = methodCall.arguments['restOptions'] as Map; + expect(restOptions['apiName'], 'restapi'); expect(restOptions['path'], '/items'); - - return {'data': responseData, 'statusCode': statusOK}; + expect(restOptions['body'], encodedMowLoanBody); + expect(restOptions['queryParameters'], queryParameters); + expect(restOptions['headers'], headers); + return {'data': helloResponse, 'statusCode': statusOK}; } }); - RestOperation restOperation = api.delete( - restOptions: const RestOptions( - path: '/items', - )); - - RestResponse response = await restOperation.response; + final restOperation = api.delete( + '/items', + body: HttpPayload.string(mowLawnBody), + apiName: 'restapi', + queryParameters: queryParameters, + headers: headers, + ); - expect(response.data, responseData); + final response = await restOperation.value; + await _assertResponse(response); }); - test('GET Status Code Error throws proper error', () async { + test( + 'POST with form-encoded body gets proper response with response headers included', + () async { apiChannel.setMockMethodCallHandler((MethodCall methodCall) async { - if (methodCall.method == 'get') { - throw PlatformException(code: 'ApiException', details: { - 'message': 'AMPLIFY_API_MUTATE_FAILED', - 'recoverySuggestion': 'some insightful suggestion', - 'underlyingException': 'Act of God' - }); + if (methodCall.method == 'post') { + Map<dynamic, dynamic> restOptions = + methodCall.arguments['restOptions'] as Map; + expect(restOptions['apiName'], 'restapi'); + expect(restOptions['path'], '/items'); + expect(restOptions['queryParameters'], queryParameters); + expect(restOptions['headers'][AWSHeaders.contentType], + 'application/x-www-form-urlencoded'); + expect(utf8.decode(restOptions['body'] as List<int>), 'foo=bar'); + return { + 'data': helloResponse, + 'statusCode': statusOK, + 'headers': {'foo': 'bar'} + }; } }); - try { - RestOperation restOperation = api.get( - restOptions: const RestOptions( - path: '/items', - )); - await restOperation.response; - } on ApiException catch (e) { - expect(e.message, 'AMPLIFY_API_MUTATE_FAILED'); - expect(e.recoverySuggestion, 'some insightful suggestion'); - expect(e.underlyingException, 'Act of God'); - } + final restOperation = api.post( + '/items', + apiName: 'restapi', + body: HttpPayload.formFields({'foo': 'bar'}), + queryParameters: queryParameters, + ); + + final response = await restOperation.value; + expect(response.headers['foo'], 'bar'); + await _assertResponse(response); }); - test('GET exception adds the httpStatusCode to exception if available', + test( + 'POST with json-encoded body has property Content-Type and gets proper response', () async { - const statusCode = 500; - const data = 'Internal server error'; - apiChannel.setMockMethodCallHandler((MethodCall methodCall) async { - if (methodCall.method == 'get') { + if (methodCall.method == 'post') { + Map<dynamic, dynamic> restOptions = + methodCall.arguments['restOptions'] as Map; + expect(restOptions['apiName'], 'restapi'); + expect(restOptions['path'], '/items'); + expect(restOptions['queryParameters'], queryParameters); + expect( + restOptions['headers'][AWSHeaders.contentType], 'application/json'); + expect(utf8.decode(restOptions['body'] as List<int>), '{"foo":"bar"}'); return { - 'statusCode': statusCode, - 'headers': <String, String>{}, - 'data': Uint8List.fromList(data.codeUnits), + 'data': helloResponse, + 'statusCode': statusOK, + 'headers': {'foo': 'bar'} }; } }); - try { - RestOperation restOperation = api.get( - restOptions: const RestOptions( - path: '/items', - ), - ); - await restOperation.response; - } on RestException catch (e) { - expect(e.response.statusCode, 500); - expect(e.response.body, data); - } + final restOperation = api.post( + '/items', + apiName: 'restapi', + body: HttpPayload.json({'foo': 'bar'}), + queryParameters: queryParameters, + ); + + final response = await restOperation.value; + await _assertResponse(response); }); test('CANCEL success does not throw error', () async { @@ -237,50 +240,9 @@ void main() { } }); - RestOperation restOperation = api.get( - restOptions: const RestOptions( - path: '/items', - )); + final restOperation = api.get('/items'); //RestResponse response = await restOperation.response; restOperation.cancel(); }); - - group('non-2xx status code', () { - const testBody = 'test'; - const testResponseHeaders = {'key': 'value'}; - - setUpAll(() { - apiChannel.setMockMethodCallHandler((call) async { - return { - 'data': utf8.encode(testBody), - 'statusCode': statusBadRequest, - 'headers': testResponseHeaders, - }; - }); - }); - - test('throws RestException', () async { - final restOp = api.get(restOptions: const RestOptions(path: '/')); - await expectLater(restOp.response, throwsRestException); - }); - - test('has valid RestResponse', () async { - final restOp = api.get(restOptions: const RestOptions(path: '/')); - - RestException restException; - try { - await restOp.response; - fail('RestOperation should throw'); - } on Exception catch (e) { - expect(e, isA<RestException>()); - restException = e as RestException; - } - - final response = restException.response; - expect(response.statusCode, statusBadRequest); - expect(response.headers, testResponseHeaders); - expect(response.body, testBody); - }); - }); } diff --git a/packages/auth/amplify_auth_cognito/example/lib/main.dart b/packages/auth/amplify_auth_cognito/example/lib/main.dart index 8a3980458b..1285d917bc 100644 --- a/packages/auth/amplify_auth_cognito/example/lib/main.dart +++ b/packages/auth/amplify_auth_cognito/example/lib/main.dart @@ -12,9 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; import 'package:amplify_api/amplify_api.dart'; import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; @@ -167,14 +165,13 @@ class _HomeScreenState extends State<HomeScreen> { try { final response = await Amplify.API .post( - restOptions: RestOptions( - path: '/hello', - body: utf8.encode(_controller.text) as Uint8List, - ), + '/hello', + body: HttpPayload.string(_controller.text), ) - .response; + .value; + final decodedBody = await response.decodeBody(); setState(() { - _greeting = response.body; + _greeting = decodedBody; }); } on Exception catch (e) { setState(() { From 6f4c84f19a06ee7c2f8d348377d9973ac0535c5a Mon Sep 17 00:00:00 2001 From: Elijah Quartey <Equartey@users.noreply.github.com> Date: Thu, 23 Jun 2022 11:39:46 -0500 Subject: [PATCH 02/33] chore(api): API Native Bridge for .addPlugin() (#1756) --- packages/api/amplify_api/Makefile | 4 + packages/api/amplify_api/example/pubspec.yaml | 3 +- packages/api/amplify_api/lib/amplify_api.dart | 21 ++-- .../amplify_api/lib/src/api_plugin_impl.dart | 81 ++++++++++++++ .../lib/src/native_api_plugin.dart | 63 +++++++++++ .../pigeons/native_api_plugin.dart | 43 ++++++++ packages/api/amplify_api/pubspec.yaml | 10 +- .../amplify/amplify_api/AmplifyApi.kt | 52 +++++---- .../amplify_api/NativeApiPluginBindings.java | 87 +++++++++++++++ packages/api/amplify_api_android/pubspec.yaml | 3 +- .../ios/Classes/NativeApiPlugin.h | 35 ++++++ .../ios/Classes/NativeApiPlugin.m | 102 ++++++++++++++++++ .../ios/Classes/SwiftAmplifyApiPlugin.swift | 43 ++++---- .../ios/Classes/amplify_api_ios.h | 21 ++++ .../ios/amplify_api_ios.podspec | 14 +++ .../api/amplify_api_ios/ios/module.modulemap | 6 ++ packages/api/amplify_api_ios/pubspec.yaml | 3 +- 17 files changed, 526 insertions(+), 65 deletions(-) create mode 100644 packages/api/amplify_api/Makefile create mode 100644 packages/api/amplify_api/lib/src/api_plugin_impl.dart create mode 100644 packages/api/amplify_api/lib/src/native_api_plugin.dart create mode 100644 packages/api/amplify_api/pigeons/native_api_plugin.dart create mode 100644 packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java create mode 100644 packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h create mode 100644 packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m create mode 100644 packages/api/amplify_api_ios/ios/Classes/amplify_api_ios.h create mode 100644 packages/api/amplify_api_ios/ios/module.modulemap diff --git a/packages/api/amplify_api/Makefile b/packages/api/amplify_api/Makefile new file mode 100644 index 0000000000..f1c3ac38ba --- /dev/null +++ b/packages/api/amplify_api/Makefile @@ -0,0 +1,4 @@ +.PHONY: pigeons +pigeons: + flutter pub run pigeon --input pigeons/native_api_plugin.dart + flutter format --fix lib/src/native_api_plugin.dart diff --git a/packages/api/amplify_api/example/pubspec.yaml b/packages/api/amplify_api/example/pubspec.yaml index 7b017f9370..9a2ddbe654 100644 --- a/packages/api/amplify_api/example/pubspec.yaml +++ b/packages/api/amplify_api/example/pubspec.yaml @@ -32,7 +32,8 @@ dependencies: sdk: flutter dev_dependencies: - amplify_lints: ^2.0.0 + amplify_lints: + path: ../../../amplify_lints amplify_test: path: ../../../amplify_test flutter_driver: diff --git a/packages/api/amplify_api/lib/amplify_api.dart b/packages/api/amplify_api/lib/amplify_api.dart index f0ca3c2c4f..a4db7b1e97 100644 --- a/packages/api/amplify_api/lib/amplify_api.dart +++ b/packages/api/amplify_api/lib/amplify_api.dart @@ -15,9 +15,7 @@ library amplify_api_plugin; -import 'dart:io'; - -import 'package:amplify_api/src/method_channel_api.dart'; +import 'package:amplify_api/src/api_plugin_impl.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:meta/meta.dart'; @@ -32,18 +30,11 @@ export './model_subscriptions.dart'; /// {@endtemplate} abstract class AmplifyAPI extends APIPluginInterface { /// {@macro amplify_api.amplify_api} - factory AmplifyAPI({ - List<APIAuthProvider> authProviders = const [], - ModelProviderInterface? modelProvider, - }) { - if (zIsWeb || Platform.isWindows || Platform.isMacOS || Platform.isLinux) { - throw UnsupportedError('This platform is not supported yet'); - } - return AmplifyAPIMethodChannel( - authProviders: authProviders, - modelProvider: modelProvider, - ); - } + factory AmplifyAPI( + {List<APIAuthProvider> authProviders = const [], + ModelProviderInterface? modelProvider}) => + AmplifyAPIDart( + authProviders: authProviders, modelProvider: modelProvider); /// Protected constructor for subclasses. @protected diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart new file mode 100644 index 0000000000..5ac4fc36ff --- /dev/null +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -0,0 +1,81 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +library amplify_api; + +import 'dart:io'; + +import 'package:amplify_api/amplify_api.dart'; +import 'package:amplify_api/src/native_api_plugin.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter/services.dart'; + +/// {@template amplify_api.amplify_api_dart} +/// The AWS implementation of the Amplify API category. +/// {@endtemplate} +class AmplifyAPIDart extends AmplifyAPI { + late final AWSApiPluginConfig _apiConfig; + + /// The registered [APIAuthProvider] instances. + final Map<APIAuthorizationType, APIAuthProvider> _authProviders = {}; + + /// {@macro amplify_api.amplify_api_dart} + AmplifyAPIDart({ + List<APIAuthProvider> authProviders = const [], + this.modelProvider, + }) : super.protected() { + authProviders.forEach(registerAuthProvider); + } + + @override + Future<void> configure({AmplifyConfig? config}) async { + final apiConfig = config?.api?.awsPlugin; + if (apiConfig == null) { + throw const ApiException('No AWS API config found', + recoverySuggestion: 'Add API from the Amplify CLI. See ' + 'https://docs.amplify.aws/lib/graphqlapi/getting-started/q/platform/flutter/#configure-api'); + } + _apiConfig = apiConfig; + } + + @override + Future<void> addPlugin() async { + if (zIsWeb || !(Platform.isAndroid || Platform.isIOS)) { + return; + } + + final nativeBridge = NativeApiBridge(); + try { + final authProvidersList = + _authProviders.keys.map((key) => key.rawValue).toList(); + await nativeBridge.addPlugin(authProvidersList); + } on PlatformException catch (e) { + if (e.code == 'AmplifyAlreadyConfiguredException') { + throw const AmplifyAlreadyConfiguredException( + AmplifyExceptionMessages.alreadyConfiguredDefaultMessage, + recoverySuggestion: + AmplifyExceptionMessages.alreadyConfiguredDefaultSuggestion); + } + throw AmplifyException.fromMap((e.details as Map).cast()); + } + } + + @override + final ModelProviderInterface? modelProvider; + + @override + void registerAuthProvider(APIAuthProvider authProvider) { + _authProviders[authProvider.type] = authProvider; + } +} diff --git a/packages/api/amplify_api/lib/src/native_api_plugin.dart b/packages/api/amplify_api/lib/src/native_api_plugin.dart new file mode 100644 index 0000000000..e7c5af4d04 --- /dev/null +++ b/packages/api/amplify_api/lib/src/native_api_plugin.dart @@ -0,0 +1,63 @@ +// +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. +// +// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name +// @dart = 2.12 +import 'dart:async'; +import 'dart:typed_data' show Uint8List, Int32List, Int64List, Float64List; + +import 'package:flutter/foundation.dart' show WriteBuffer, ReadBuffer; +import 'package:flutter/services.dart'; + +class _NativeApiBridgeCodec extends StandardMessageCodec { + const _NativeApiBridgeCodec(); +} + +class NativeApiBridge { + /// Constructor for [NativeApiBridge]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + NativeApiBridge({BinaryMessenger? binaryMessenger}) + : _binaryMessenger = binaryMessenger; + + final BinaryMessenger? _binaryMessenger; + + static const MessageCodec<Object?> codec = _NativeApiBridgeCodec(); + + Future<void> addPlugin(List<String?> arg_authProvidersList) async { + final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>( + 'dev.flutter.pigeon.NativeApiBridge.addPlugin', codec, + binaryMessenger: _binaryMessenger); + final Map<Object?, Object?>? replyMap = await channel + .send(<Object?>[arg_authProvidersList]) as Map<Object?, Object?>?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map<Object?, Object?> error = + (replyMap['error'] as Map<Object?, Object?>?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } +} diff --git a/packages/api/amplify_api/pigeons/native_api_plugin.dart b/packages/api/amplify_api/pigeons/native_api_plugin.dart new file mode 100644 index 0000000000..a36f7397f9 --- /dev/null +++ b/packages/api/amplify_api/pigeons/native_api_plugin.dart @@ -0,0 +1,43 @@ +// +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. +// + +// ignore_for_file: avoid_positional_boolean_parameters + +@ConfigurePigeon( + PigeonOptions( + copyrightHeader: '../../../tool/license.txt', + dartOptions: DartOptions(), + dartOut: 'lib/src/native_Api_plugin.dart', + javaOptions: JavaOptions( + className: 'NativeApiPluginBindings', + package: 'com.amazonaws.amplify.amplify_api', + ), + javaOut: + '../amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java', + objcOptions: ObjcOptions( + header: 'NativeApiPlugin.h', + ), + objcHeaderOut: '../amplify_api_ios/ios/Classes/NativeApiPlugin.h', + objcSourceOut: '../amplify_api_ios/ios/Classes/NativeApiPlugin.m', + ), +) +library native_api_plugin; + +import 'package:pigeon/pigeon.dart'; + +@HostApi() +abstract class NativeApiBridge { + void addPlugin(List<String> authProvidersList); +} diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index 869779172e..b65a92bfec 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -21,14 +21,22 @@ dependencies: meta: ^1.7.0 plugin_platform_interface: ^2.0.0 +dependency_overrides: + # TODO(dnys1): Remove when pigeon is bumped + # https://github.com/flutter/flutter/issues/105090 + analyzer: ^3.0.0 + + dev_dependencies: - amplify_lints: ^2.0.0 + amplify_lints: + path: ../../amplify_lints amplify_test: path: ../../amplify_test build_runner: ^2.0.0 flutter_test: sdk: flutter mockito: ^5.0.0 + pigeon: ^3.1.5 # The following section is specific to Flutter. flutter: diff --git a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt index 02de711722..0205877bf7 100644 --- a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt +++ b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt @@ -39,7 +39,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers /** AmplifyApiPlugin */ -class AmplifyApi : FlutterPlugin, MethodCallHandler { +class AmplifyApi : FlutterPlugin, MethodCallHandler, NativeApiPluginBindings.NativeApiBridge { private companion object { /** @@ -83,6 +83,11 @@ class AmplifyApi : FlutterPlugin, MethodCallHandler { "com.amazonaws.amplify/api_observe_events" ) eventchannel!!.setStreamHandler(graphqlSubscriptionStreamHandler) + + NativeApiPluginBindings.NativeApiBridge.setup( + flutterPluginBinding.binaryMessenger, + this + ) } @Suppress("UNCHECKED_CAST") @@ -94,27 +99,6 @@ class AmplifyApi : FlutterPlugin, MethodCallHandler { if (methodName == "cancel") { onCancel(result, (call.arguments as String)) return - } else if (methodName == "addPlugin") { - try { - val authProvidersList: List<String> = - (arguments["authProviders"] as List<*>?)?.cast() ?: listOf() - val authProviders = authProvidersList.map { AuthorizationType.valueOf(it) } - if (flutterAuthProviders == null) { - flutterAuthProviders = FlutterAuthProviders(authProviders) - } - flutterAuthProviders!!.setMethodChannel(channel) - Amplify.addPlugin( - AWSApiPlugin - .builder() - .apiAuthProviders(flutterAuthProviders!!.factory) - .build() - ) - logger.info("Added API plugin") - result.success(null) - } catch (e: Exception) { - handleAddPluginException("API", e, result) - } - return } try { @@ -168,5 +152,29 @@ class AmplifyApi : FlutterPlugin, MethodCallHandler { eventchannel = null graphqlSubscriptionStreamHandler?.close() graphqlSubscriptionStreamHandler = null + + NativeApiPluginBindings.NativeApiBridge.setup( + flutterPluginBinding.binaryMessenger, + null, + ) + } + + override fun addPlugin(authProvidersList: MutableList<String>) { + try { + val authProviders = authProvidersList.map { AuthorizationType.valueOf(it) } + if (flutterAuthProviders == null) { + flutterAuthProviders = FlutterAuthProviders(authProviders) + } + flutterAuthProviders!!.setMethodChannel(channel) + Amplify.addPlugin( + AWSApiPlugin + .builder() + .apiAuthProviders(flutterAuthProviders!!.factory) + .build() + ) + logger.info("Added API plugin") + } catch (e: Exception) { + logger.error(e.message) + } } } diff --git a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java new file mode 100644 index 0000000000..d8d07f4add --- /dev/null +++ b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java @@ -0,0 +1,87 @@ +// +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. +// +// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +package com.amazonaws.amplify.amplify_api; + +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.flutter.plugin.common.BasicMessageChannel; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MessageCodec; +import io.flutter.plugin.common.StandardMessageCodec; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.HashMap; + +/** Generated class from Pigeon. */ +@SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression"}) +public class NativeApiPluginBindings { + private static class NativeApiBridgeCodec extends StandardMessageCodec { + public static final NativeApiBridgeCodec INSTANCE = new NativeApiBridgeCodec(); + private NativeApiBridgeCodec() {} + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter.*/ + public interface NativeApiBridge { + void addPlugin(@NonNull List<String> authProvidersList); + + /** The codec used by NativeApiBridge. */ + static MessageCodec<Object> getCodec() { + return NativeApiBridgeCodec.INSTANCE; + } + + /** Sets up an instance of `NativeApiBridge` to handle messages through the `binaryMessenger`. */ + static void setup(BinaryMessenger binaryMessenger, NativeApiBridge api) { + { + BasicMessageChannel<Object> channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.NativeApiBridge.addPlugin", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map<String, Object> wrapped = new HashMap<>(); + try { + ArrayList<Object> args = (ArrayList<Object>)message; + List<String> authProvidersListArg = (List<String>)args.get(0); + if (authProvidersListArg == null) { + throw new NullPointerException("authProvidersListArg unexpectedly null."); + } + api.addPlugin(authProvidersListArg); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + } + } + private static Map<String, Object> wrapError(Throwable exception) { + Map<String, Object> errorMap = new HashMap<>(); + errorMap.put("message", exception.toString()); + errorMap.put("code", exception.getClass().getSimpleName()); + errorMap.put("details", "Cause: " + exception.getCause() + ", Stacktrace: " + Log.getStackTraceString(exception)); + return errorMap; + } +} diff --git a/packages/api/amplify_api_android/pubspec.yaml b/packages/api/amplify_api_android/pubspec.yaml index 186f102aff..920cdc661b 100644 --- a/packages/api/amplify_api_android/pubspec.yaml +++ b/packages/api/amplify_api_android/pubspec.yaml @@ -12,7 +12,8 @@ dependencies: sdk: flutter dev_dependencies: - amplify_lints: ^2.0.0 + amplify_lints: + path: ../../amplify_lints flutter_test: sdk: flutter diff --git a/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h new file mode 100644 index 0000000000..7b3bad24ed --- /dev/null +++ b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h @@ -0,0 +1,35 @@ +// +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. +// +// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// See also: https://pub.dev/packages/pigeon +#import <Foundation/Foundation.h> +@protocol FlutterBinaryMessenger; +@protocol FlutterMessageCodec; +@class FlutterError; +@class FlutterStandardTypedData; + +NS_ASSUME_NONNULL_BEGIN + + +/// The codec used by NativeApiBridge. +NSObject<FlutterMessageCodec> *NativeApiBridgeGetCodec(void); + +@protocol NativeApiBridge +- (void)addPluginAuthProvidersList:(NSArray<NSString *> *)authProvidersList error:(FlutterError *_Nullable *_Nonnull)error; +@end + +extern void NativeApiBridgeSetup(id<FlutterBinaryMessenger> binaryMessenger, NSObject<NativeApiBridge> *_Nullable api); + +NS_ASSUME_NONNULL_END diff --git a/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m new file mode 100644 index 0000000000..c936591be5 --- /dev/null +++ b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m @@ -0,0 +1,102 @@ +// +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. +// +// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// See also: https://pub.dev/packages/pigeon +#import "NativeApiPlugin.h" +#import <Flutter/Flutter.h> + +#if !__has_feature(objc_arc) +#error File requires ARC to be enabled. +#endif + +static NSDictionary<NSString *, id> *wrapResult(id result, FlutterError *error) { + NSDictionary *errorDict = (NSDictionary *)[NSNull null]; + if (error) { + errorDict = @{ + @"code": (error.code ?: [NSNull null]), + @"message": (error.message ?: [NSNull null]), + @"details": (error.details ?: [NSNull null]), + }; + } + return @{ + @"result": (result ?: [NSNull null]), + @"error": errorDict, + }; +} +static id GetNullableObject(NSDictionary* dict, id key) { + id result = dict[key]; + return (result == [NSNull null]) ? nil : result; +} +static id GetNullableObjectAtIndex(NSArray* array, NSInteger key) { + id result = array[key]; + return (result == [NSNull null]) ? nil : result; +} + + + +@interface NativeApiBridgeCodecReader : FlutterStandardReader +@end +@implementation NativeApiBridgeCodecReader +@end + +@interface NativeApiBridgeCodecWriter : FlutterStandardWriter +@end +@implementation NativeApiBridgeCodecWriter +@end + +@interface NativeApiBridgeCodecReaderWriter : FlutterStandardReaderWriter +@end +@implementation NativeApiBridgeCodecReaderWriter +- (FlutterStandardWriter *)writerWithData:(NSMutableData *)data { + return [[NativeApiBridgeCodecWriter alloc] initWithData:data]; +} +- (FlutterStandardReader *)readerWithData:(NSData *)data { + return [[NativeApiBridgeCodecReader alloc] initWithData:data]; +} +@end + +NSObject<FlutterMessageCodec> *NativeApiBridgeGetCodec() { + static dispatch_once_t sPred = 0; + static FlutterStandardMessageCodec *sSharedObject = nil; + dispatch_once(&sPred, ^{ + NativeApiBridgeCodecReaderWriter *readerWriter = [[NativeApiBridgeCodecReaderWriter alloc] init]; + sSharedObject = [FlutterStandardMessageCodec codecWithReaderWriter:readerWriter]; + }); + return sSharedObject; +} + + +void NativeApiBridgeSetup(id<FlutterBinaryMessenger> binaryMessenger, NSObject<NativeApiBridge> *api) { + { + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:@"dev.flutter.pigeon.NativeApiBridge.addPlugin" + binaryMessenger:binaryMessenger + codec:NativeApiBridgeGetCodec() ]; + if (api) { + NSCAssert([api respondsToSelector:@selector(addPluginAuthProvidersList:error:)], @"NativeApiBridge api (%@) doesn't respond to @selector(addPluginAuthProvidersList:error:)", api); + [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { + NSArray *args = message; + NSArray<NSString *> *arg_authProvidersList = GetNullableObjectAtIndex(args, 0); + FlutterError *error; + [api addPluginAuthProvidersList:arg_authProvidersList error:&error]; + callback(wrapResult(nil, error)); + }]; + } + else { + [channel setMessageHandler:nil]; + } + } +} diff --git a/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift b/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift index 7ad1accd1a..63ce5c373c 100644 --- a/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift +++ b/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift @@ -20,7 +20,7 @@ import AmplifyPlugins import amplify_flutter_ios import AWSPluginsCore -public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin { +public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin, NativeApiBridge { private let bridge: ApiBridge private let graphQLSubscriptionsStreamHandler: GraphQLSubscriptionsStreamHandler static var methodChannel: FlutterMethodChannel! @@ -43,6 +43,7 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin { let instance = SwiftAmplifyApiPlugin() eventchannel.setStreamHandler(instance.graphQLSubscriptionsStreamHandler) registrar.addMethodCallDelegate(instance, channel: methodChannel) + NativeApiBridgeSetup(registrar.messenger(), instance) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { @@ -62,33 +63,26 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin { let arguments = try FlutterApiRequest.getMap(args: callArgs) - if method == "addPlugin"{ - let authProvidersList = arguments["authProviders"] as? [String] ?? [] - let authProviders = authProvidersList.compactMap { - AWSAuthorizationType(rawValue: $0) - } - addPlugin(authProviders: authProviders, result: result) - return - } - try innerHandle(method: method, arguments: arguments, result: result) } catch { print("Failed to parse query arguments with \(error)") FlutterApiErrorHandler.handleApiError(error: APIError(error: error), flutterResult: result) } } - - private func addPlugin(authProviders: [AWSAuthorizationType], result: FlutterResult) { + + public func addPluginAuthProvidersList(_ authProvidersList: [String], error: AutoreleasingUnsafeMutablePointer<FlutterError?>) { do { + let authProviders = authProvidersList.compactMap { + AWSAuthorizationType(rawValue: $0) + } try Amplify.add( plugin: AWSAPIPlugin( sessionFactory: FlutterURLSessionBehaviorFactory(), apiAuthProviderFactory: FlutterAuthProviders(authProviders))) - result(true) } catch let apiError as APIError { - ErrorUtil.postErrorToFlutterChannel( - result: result, - errorCode: "APIException", + error.pointee = FlutterError( + code: "APIException", + message: apiError.localizedDescription, details: [ "message": apiError.errorDescription, "recoverySuggestion": apiError.recoverySuggestion, @@ -100,20 +94,21 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin { if case .amplifyAlreadyConfigured = configError { errorCode = "AmplifyAlreadyConfiguredException" } - ErrorUtil.postErrorToFlutterChannel( - result: result, - errorCode: errorCode, + error.pointee = FlutterError( + code: errorCode, + message: configError.localizedDescription, details: [ "message": configError.errorDescription, "recoverySuggestion": configError.recoverySuggestion, "underlyingError": configError.underlyingError?.localizedDescription ?? "" ] ) - } catch { - ErrorUtil.postErrorToFlutterChannel( - result: result, - errorCode: "UNKNOWN", - details: ["message": error.localizedDescription]) + } catch let e { + error.pointee = FlutterError( + code: "UNKNOWN", + message: e.localizedDescription, + details: nil + ) } } diff --git a/packages/api/amplify_api_ios/ios/Classes/amplify_api_ios.h b/packages/api/amplify_api_ios/ios/Classes/amplify_api_ios.h new file mode 100644 index 0000000000..0b890efd4f --- /dev/null +++ b/packages/api/amplify_api_ios/ios/Classes/amplify_api_ios.h @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +#ifndef amplify_api_ios_h +#define amplify_api_ios_h + +#import "NativeApiPlugin.h" +#import "AmplifyApi.h" + +#endif /* amplify_api_ios_h */ diff --git a/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec b/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec index 181063b97c..276c97012b 100644 --- a/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec +++ b/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec @@ -21,6 +21,20 @@ The API module for Amplify Flutter. s.platform = :ios, '11.0' s.swift_version = '5.0' + # Use a custom module map with a manually written umbrella header. + # + # Since we use `package:pigeon` to generate our platform interface + # in ObjC, and since the rest of the module is written in Swift, we + # fall victim to this issue: https://github.com/CocoaPods/CocoaPods/issues/10544 + # + # This is because we have an ObjC -> Swift -> ObjC import cycle: + # ApiPlugin -> SwiftAmplifyApiPlugin -> NativeApiPlugin + # + # The easiest solution to this problem is to create the umbrella + # header which would otherwise be auto-generated by Cocoapods but + # name it what's expected by the Swift compiler (amplify_api_ios.h). + s.module_map = 'module.modulemap' + # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } end diff --git a/packages/api/amplify_api_ios/ios/module.modulemap b/packages/api/amplify_api_ios/ios/module.modulemap new file mode 100644 index 0000000000..acac87c311 --- /dev/null +++ b/packages/api/amplify_api_ios/ios/module.modulemap @@ -0,0 +1,6 @@ +framework module amplify_api_ios { + umbrella header "amplify_api_ios.h" + + export * + module * { export * } +} diff --git a/packages/api/amplify_api_ios/pubspec.yaml b/packages/api/amplify_api_ios/pubspec.yaml index 75c7a121e6..eea5904683 100644 --- a/packages/api/amplify_api_ios/pubspec.yaml +++ b/packages/api/amplify_api_ios/pubspec.yaml @@ -13,7 +13,8 @@ dependencies: sdk: flutter dev_dependencies: - amplify_lints: ^2.0.0 + amplify_lints: + path: ../../amplify_lints flutter_test: sdk: flutter From 7b4e0caeb4a8ce1866a69720489bf8d27b03313f Mon Sep 17 00:00:00 2001 From: Elijah Quartey <Equartey@users.noreply.github.com> Date: Mon, 27 Jun 2022 11:43:25 -0500 Subject: [PATCH 03/33] chore(api): API Pigeon update (#1813) --- .../lib/src/native_api_plugin.dart | 2 +- .../pigeons/native_api_plugin.dart | 1 + packages/api/amplify_api/pubspec.yaml | 8 +----- .../amplify/amplify_api/AmplifyApi.kt | 7 +++++- .../amplify_api/NativeApiPluginBindings.java | 25 +++++++++++++++---- .../ios/Classes/NativeApiPlugin.h | 4 +-- .../ios/Classes/NativeApiPlugin.m | 10 ++++---- .../ios/Classes/SwiftAmplifyApiPlugin.swift | 9 ++++--- .../ios/amplify_api_ios.podspec | 6 +++-- 9 files changed, 45 insertions(+), 27 deletions(-) diff --git a/packages/api/amplify_api/lib/src/native_api_plugin.dart b/packages/api/amplify_api/lib/src/native_api_plugin.dart index e7c5af4d04..3ff74bd774 100644 --- a/packages/api/amplify_api/lib/src/native_api_plugin.dart +++ b/packages/api/amplify_api/lib/src/native_api_plugin.dart @@ -12,7 +12,7 @@ // express or implied. See the License for the specific language governing // permissions and limitations under the License. // -// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// Autogenerated from Pigeon (v3.2.0), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name // @dart = 2.12 diff --git a/packages/api/amplify_api/pigeons/native_api_plugin.dart b/packages/api/amplify_api/pigeons/native_api_plugin.dart index a36f7397f9..0e54029724 100644 --- a/packages/api/amplify_api/pigeons/native_api_plugin.dart +++ b/packages/api/amplify_api/pigeons/native_api_plugin.dart @@ -39,5 +39,6 @@ import 'package:pigeon/pigeon.dart'; @HostApi() abstract class NativeApiBridge { + @async void addPlugin(List<String> authProvidersList); } diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index b65a92bfec..2bc42e55cb 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -21,12 +21,6 @@ dependencies: meta: ^1.7.0 plugin_platform_interface: ^2.0.0 -dependency_overrides: - # TODO(dnys1): Remove when pigeon is bumped - # https://github.com/flutter/flutter/issues/105090 - analyzer: ^3.0.0 - - dev_dependencies: amplify_lints: path: ../../amplify_lints @@ -36,7 +30,7 @@ dev_dependencies: flutter_test: sdk: flutter mockito: ^5.0.0 - pigeon: ^3.1.5 + pigeon: ^3.1.6 # The following section is specific to Flutter. flutter: diff --git a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt index 0205877bf7..e49a66932a 100644 --- a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt +++ b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt @@ -159,7 +159,10 @@ class AmplifyApi : FlutterPlugin, MethodCallHandler, NativeApiPluginBindings.Nat ) } - override fun addPlugin(authProvidersList: MutableList<String>) { + override fun addPlugin( + authProvidersList: MutableList<String>, + result: NativeApiPluginBindings.Result<Void> + ) { try { val authProviders = authProvidersList.map { AuthorizationType.valueOf(it) } if (flutterAuthProviders == null) { @@ -173,8 +176,10 @@ class AmplifyApi : FlutterPlugin, MethodCallHandler, NativeApiPluginBindings.Nat .build() ) logger.info("Added API plugin") + result.success(null) } catch (e: Exception) { logger.error(e.message) + result.error(e) } } } diff --git a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java index d8d07f4add..70c59352c8 100644 --- a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java +++ b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java @@ -12,7 +12,7 @@ // express or implied. See the License for the specific language governing // permissions and limitations under the License. // -// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// Autogenerated from Pigeon (v3.2.0), do not edit directly. // See also: https://pub.dev/packages/pigeon package com.amazonaws.amplify.amplify_api; @@ -35,6 +35,11 @@ /** Generated class from Pigeon. */ @SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression"}) public class NativeApiPluginBindings { + + public interface Result<T> { + void success(T result); + void error(Throwable error); + } private static class NativeApiBridgeCodec extends StandardMessageCodec { public static final NativeApiBridgeCodec INSTANCE = new NativeApiBridgeCodec(); private NativeApiBridgeCodec() {} @@ -42,7 +47,7 @@ private NativeApiBridgeCodec() {} /** Generated interface from Pigeon that represents a handler of messages from Flutter.*/ public interface NativeApiBridge { - void addPlugin(@NonNull List<String> authProvidersList); + void addPlugin(@NonNull List<String> authProvidersList, Result<Void> result); /** The codec used by NativeApiBridge. */ static MessageCodec<Object> getCodec() { @@ -63,13 +68,23 @@ static void setup(BinaryMessenger binaryMessenger, NativeApiBridge api) { if (authProvidersListArg == null) { throw new NullPointerException("authProvidersListArg unexpectedly null."); } - api.addPlugin(authProvidersListArg); - wrapped.put("result", null); + Result<Void> resultCallback = new Result<Void>() { + public void success(Void result) { + wrapped.put("result", null); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.addPlugin(authProvidersListArg, resultCallback); } catch (Error | RuntimeException exception) { wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); } - reply.reply(wrapped); }); } else { channel.setMessageHandler(null); diff --git a/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h index 7b3bad24ed..cf89fcb539 100644 --- a/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h +++ b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h @@ -12,7 +12,7 @@ // express or implied. See the License for the specific language governing // permissions and limitations under the License. // -// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// Autogenerated from Pigeon (v3.2.0), do not edit directly. // See also: https://pub.dev/packages/pigeon #import <Foundation/Foundation.h> @protocol FlutterBinaryMessenger; @@ -27,7 +27,7 @@ NS_ASSUME_NONNULL_BEGIN NSObject<FlutterMessageCodec> *NativeApiBridgeGetCodec(void); @protocol NativeApiBridge -- (void)addPluginAuthProvidersList:(NSArray<NSString *> *)authProvidersList error:(FlutterError *_Nullable *_Nonnull)error; +- (void)addPluginAuthProvidersList:(NSArray<NSString *> *)authProvidersList completion:(void(^)(FlutterError *_Nullable))completion; @end extern void NativeApiBridgeSetup(id<FlutterBinaryMessenger> binaryMessenger, NSObject<NativeApiBridge> *_Nullable api); diff --git a/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m index c936591be5..bae599aa4b 100644 --- a/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m +++ b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m @@ -12,7 +12,7 @@ // express or implied. See the License for the specific language governing // permissions and limitations under the License. // -// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// Autogenerated from Pigeon (v3.2.0), do not edit directly. // See also: https://pub.dev/packages/pigeon #import "NativeApiPlugin.h" #import <Flutter/Flutter.h> @@ -86,13 +86,13 @@ void NativeApiBridgeSetup(id<FlutterBinaryMessenger> binaryMessenger, NSObject<N binaryMessenger:binaryMessenger codec:NativeApiBridgeGetCodec() ]; if (api) { - NSCAssert([api respondsToSelector:@selector(addPluginAuthProvidersList:error:)], @"NativeApiBridge api (%@) doesn't respond to @selector(addPluginAuthProvidersList:error:)", api); + NSCAssert([api respondsToSelector:@selector(addPluginAuthProvidersList:completion:)], @"NativeApiBridge api (%@) doesn't respond to @selector(addPluginAuthProvidersList:completion:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; NSArray<NSString *> *arg_authProvidersList = GetNullableObjectAtIndex(args, 0); - FlutterError *error; - [api addPluginAuthProvidersList:arg_authProvidersList error:&error]; - callback(wrapResult(nil, error)); + [api addPluginAuthProvidersList:arg_authProvidersList completion:^(FlutterError *_Nullable error) { + callback(wrapResult(nil, error)); + }]; }]; } else { diff --git a/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift b/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift index 63ce5c373c..01c14b8e0c 100644 --- a/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift +++ b/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift @@ -70,7 +70,7 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin, NativeApiBridge { } } - public func addPluginAuthProvidersList(_ authProvidersList: [String], error: AutoreleasingUnsafeMutablePointer<FlutterError?>) { + public func addPluginAuthProvidersList(_ authProvidersList: [String]) async -> FlutterError? { do { let authProviders = authProvidersList.compactMap { AWSAuthorizationType(rawValue: $0) @@ -79,8 +79,9 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin, NativeApiBridge { plugin: AWSAPIPlugin( sessionFactory: FlutterURLSessionBehaviorFactory(), apiAuthProviderFactory: FlutterAuthProviders(authProviders))) + return nil } catch let apiError as APIError { - error.pointee = FlutterError( + return FlutterError( code: "APIException", message: apiError.localizedDescription, details: [ @@ -94,7 +95,7 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin, NativeApiBridge { if case .amplifyAlreadyConfigured = configError { errorCode = "AmplifyAlreadyConfiguredException" } - error.pointee = FlutterError( + return FlutterError( code: errorCode, message: configError.localizedDescription, details: [ @@ -104,7 +105,7 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin, NativeApiBridge { ] ) } catch let e { - error.pointee = FlutterError( + return FlutterError( code: "UNKNOWN", message: e.localizedDescription, details: nil diff --git a/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec b/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec index 276c97012b..f5a6147bff 100644 --- a/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec +++ b/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec @@ -18,8 +18,10 @@ The API module for Amplify Flutter. s.dependency 'Amplify', '1.23.0' s.dependency 'AmplifyPlugins/AWSAPIPlugin', '1.23.0' s.dependency 'amplify_flutter_ios' - s.platform = :ios, '11.0' - s.swift_version = '5.0' + + # These are needed to support async/await with pigeon + s.platform = :ios, '13.0' + s.swift_version = '5.5' # Use a custom module map with a manually written umbrella header. # From 26b23b608d53fdee3787f91bce09a5a03138307f Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Mon, 27 Jun 2022 14:28:39 -0800 Subject: [PATCH 04/33] feat(api): REST methods in dart with auth mode none (#1783) --- .../amplify_flutter/lib/src/hybrid_impl.dart | 1 + .../lib/src/amplify_api_config.dart | 74 ++++++++ .../amplify_authorization_rest_client.dart | 58 +++++++ .../amplify_api/lib/src/api_plugin_impl.dart | 163 +++++++++++++++++- .../lib/src/method_channel_api.dart | 10 +- packages/api/amplify_api/lib/src/util.dart | 32 ++++ packages/api/amplify_api/pubspec.yaml | 1 + .../test/amplify_dart_rest_methods_test.dart | 103 +++++++++++ .../test_data/fake_amplify_configuration.dart | 79 +++++++++ packages/api/amplify_api/test/util_test.dart | 42 +++++ 10 files changed, 554 insertions(+), 9 deletions(-) create mode 100644 packages/api/amplify_api/lib/src/amplify_api_config.dart create mode 100644 packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart create mode 100644 packages/api/amplify_api/lib/src/util.dart create mode 100644 packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart create mode 100644 packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart create mode 100644 packages/api/amplify_api/test/util_test.dart diff --git a/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart b/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart index a8c91af3a0..5eb3f1257e 100644 --- a/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart +++ b/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart @@ -34,6 +34,7 @@ class AmplifyHybridImpl extends AmplifyClassImpl { ); await Future.wait( [ + ...API.plugins, ...Auth.plugins, ].map((p) => p.configure(config: amplifyConfig)), eagerError: true, diff --git a/packages/api/amplify_api/lib/src/amplify_api_config.dart b/packages/api/amplify_api/lib/src/amplify_api_config.dart new file mode 100644 index 0000000000..960f11bf9b --- /dev/null +++ b/packages/api/amplify_api/lib/src/amplify_api_config.dart @@ -0,0 +1,74 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. + +import 'package:amplify_core/amplify_core.dart'; +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; + +const _slash = '/'; + +@internal +class EndpointConfig with AWSEquatable<EndpointConfig> { + const EndpointConfig(this.name, this.config); + + final String name; + final AWSApiConfig config; + + @override + List<Object?> get props => [name, config]; + + /// Gets the host with environment path prefix from Amplify config and combines + /// with [path] and [queryParameters] to return a full [Uri]. + Uri getUri(String path, Map<String, dynamic>? queryParameters) { + final parsed = Uri.parse(config.endpoint); + // Remove leading slashes which are suggested in public documentation. + // https://docs.amplify.aws/lib/restapi/getting-started/q/platform/flutter/#make-a-post-request + if (path.startsWith(_slash)) { + path = path.substring(1); + } + // Avoid URI-encoding slashes in path from caller. + final pathSegmentsFromPath = path.split(_slash); + return parsed.replace(pathSegments: [ + ...parsed.pathSegments, + ...pathSegmentsFromPath, + ], queryParameters: queryParameters); + } +} + +@internal +extension AWSApiPluginConfigHelpers on AWSApiPluginConfig { + EndpointConfig getEndpoint({ + required EndpointType type, + String? apiName, + }) { + final typeConfigs = + entries.where((config) => config.value.endpointType == type); + if (apiName != null) { + final config = typeConfigs.firstWhere( + (config) => config.key == apiName, + orElse: () => throw ApiException( + 'No API endpoint found matching apiName $apiName.', + ), + ); + return EndpointConfig(config.key, config.value); + } + final onlyConfig = typeConfigs.singleOrNull; + if (onlyConfig == null) { + throw const ApiException( + 'Multiple API endpoints defined. Pass apiName parameter to specify ' + 'which one to use.', + ); + } + return EndpointConfig(onlyConfig.key, onlyConfig.value); + } +} diff --git a/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart new file mode 100644 index 0000000000..e58885385c --- /dev/null +++ b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart @@ -0,0 +1,58 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:async'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +/// Implementation of http [http.Client] that authorizes HTTP requests with +/// Amplify. +@internal +class AmplifyAuthorizationRestClient extends http.BaseClient + implements Closeable { + /// Determines how requests with this client are authorized. + final AWSApiConfig endpointConfig; + final http.Client _baseClient; + final bool _useDefaultBaseClient; + + /// Provide an [AWSApiConfig] which will determine how requests from this + /// client are authorized. + AmplifyAuthorizationRestClient({ + required this.endpointConfig, + http.Client? baseClient, + }) : _useDefaultBaseClient = baseClient == null, + _baseClient = baseClient ?? http.Client(); + + /// Implementation of [send] that authorizes any request without "Authorization" + /// header already set. + @override + Future<http.StreamedResponse> send(http.BaseRequest request) async => + _baseClient.send(_authorizeRequest(request)); + + @override + void close() { + if (_useDefaultBaseClient) _baseClient.close(); + } + + http.BaseRequest _authorizeRequest(http.BaseRequest request) { + if (!request.headers.containsKey(AWSHeaders.authorization) && + endpointConfig.authorizationType != APIAuthorizationType.none) { + // ignore: todo + // TODO: Use auth providers from core to transform the request. + } + return request; + } +} diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index 5ac4fc36ff..0926c0a462 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -19,13 +19,25 @@ import 'dart:io'; import 'package:amplify_api/amplify_api.dart'; import 'package:amplify_api/src/native_api_plugin.dart'; import 'package:amplify_core/amplify_core.dart'; +import 'package:async/async.dart'; import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +import 'amplify_api_config.dart'; +import 'amplify_authorization_rest_client.dart'; +import 'util.dart'; /// {@template amplify_api.amplify_api_dart} /// The AWS implementation of the Amplify API category. /// {@endtemplate} class AmplifyAPIDart extends AmplifyAPI { late final AWSApiPluginConfig _apiConfig; + final http.Client? _baseHttpClient; + + /// A map of the keys from the Amplify API config to HTTP clients to use for + /// requests to that endpoint. + final Map<String, AmplifyAuthorizationRestClient> _clientPool = {}; /// The registered [APIAuthProvider] instances. final Map<APIAuthorizationType, APIAuthProvider> _authProviders = {}; @@ -33,8 +45,10 @@ class AmplifyAPIDart extends AmplifyAPI { /// {@macro amplify_api.amplify_api_dart} AmplifyAPIDart({ List<APIAuthProvider> authProviders = const [], + http.Client? baseHttpClient, this.modelProvider, - }) : super.protected() { + }) : _baseHttpClient = baseHttpClient, + super.protected() { authProviders.forEach(registerAuthProvider); } @@ -71,6 +85,43 @@ class AmplifyAPIDart extends AmplifyAPI { } } + /// Returns the HTTP client to be used for REST operations. + /// + /// Use [apiName] if there are multiple REST endpoints. + @visibleForTesting + http.Client getRestClient({String? apiName}) { + final endpoint = _apiConfig.getEndpoint( + type: EndpointType.rest, + apiName: apiName, + ); + return _clientPool[endpoint.name] ??= AmplifyAuthorizationRestClient( + endpointConfig: endpoint.config, + baseClient: _baseHttpClient, + ); + } + + Uri _getRestUri( + String path, String? apiName, Map<String, dynamic>? queryParameters) { + final endpoint = _apiConfig.getEndpoint( + type: EndpointType.rest, + apiName: apiName, + ); + return endpoint.getUri(path, queryParameters); + } + + /// NOTE: http does not support request abort https://github.com/dart-lang/http/issues/424. + /// For now, just make a [CancelableOperation] to cancel the future. + /// To actually abort calls at network layer, need to call in + /// dart:io/dart:html or other library with custom http default Client() implementation. + CancelableOperation<T> _makeCancelable<T>(Future<T> responseFuture) { + return CancelableOperation.fromFuture(responseFuture); + } + + CancelableOperation<AWSStreamedHttpResponse> _prepareRestResponse( + Future<AWSStreamedHttpResponse> responseFuture) { + return _makeCancelable(responseFuture); + } + @override final ModelProviderInterface? modelProvider; @@ -78,4 +129,114 @@ class AmplifyAPIDart extends AmplifyAPI { void registerAuthProvider(APIAuthProvider authProvider) { _authProviders[authProvider.type] = authProvider; } + + // ====== REST ======= + + @override + CancelableOperation<AWSStreamedHttpResponse> delete( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + final uri = _getRestUri(path, apiName, queryParameters); + final client = getRestClient(apiName: apiName); + return _prepareRestResponse(AWSStreamedHttpRequest.delete( + uri, + body: body ?? HttpPayload.empty(), + headers: addContentTypeToHeaders(headers, body), + ).send(client)); + } + + @override + CancelableOperation<AWSStreamedHttpResponse> get( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + final uri = _getRestUri(path, apiName, queryParameters); + final client = getRestClient(apiName: apiName); + return _prepareRestResponse( + AWSHttpRequest.get( + uri, + headers: headers, + ).send(client), + ); + } + + @override + CancelableOperation<AWSStreamedHttpResponse> head( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + final uri = _getRestUri(path, apiName, queryParameters); + final client = getRestClient(apiName: apiName); + return _prepareRestResponse( + AWSHttpRequest.head( + uri, + headers: headers, + ).send(client), + ); + } + + @override + CancelableOperation<AWSStreamedHttpResponse> patch( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + final uri = _getRestUri(path, apiName, queryParameters); + final client = getRestClient(apiName: apiName); + return _prepareRestResponse( + AWSStreamedHttpRequest.patch( + uri, + headers: addContentTypeToHeaders(headers, body), + body: body ?? HttpPayload.empty(), + ).send(client), + ); + } + + @override + CancelableOperation<AWSStreamedHttpResponse> post( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + final uri = _getRestUri(path, apiName, queryParameters); + final client = getRestClient(apiName: apiName); + return _prepareRestResponse( + AWSStreamedHttpRequest.post( + uri, + headers: addContentTypeToHeaders(headers, body), + body: body ?? HttpPayload.empty(), + ).send(client), + ); + } + + @override + CancelableOperation<AWSStreamedHttpResponse> put( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + final uri = _getRestUri(path, apiName, queryParameters); + final client = getRestClient(apiName: apiName); + return _prepareRestResponse( + AWSStreamedHttpRequest.put( + uri, + headers: addContentTypeToHeaders(headers, body), + body: body ?? HttpPayload.empty(), + ).send(client), + ); + } } diff --git a/packages/api/amplify_api/lib/src/method_channel_api.dart b/packages/api/amplify_api/lib/src/method_channel_api.dart index 95e8f5c17d..c0285a6305 100644 --- a/packages/api/amplify_api/lib/src/method_channel_api.dart +++ b/packages/api/amplify_api/lib/src/method_channel_api.dart @@ -26,6 +26,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../amplify_api.dart'; +import 'util.dart'; part 'auth_token.dart'; @@ -282,19 +283,12 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { }) { // Send Request cancelToken to Native String cancelToken = uuid(); - // Ensure Content-Type header matches payload. - var modifiedHeaders = headers != null ? Map.of(headers) : null; - final contentType = body?.contentType; - if (contentType != null) { - modifiedHeaders = modifiedHeaders ?? {}; - modifiedHeaders.putIfAbsent(AWSHeaders.contentType, () => contentType); - } final responseFuture = _restResponseHelper( methodName: methodName, path: path, cancelToken: cancelToken, body: body, - headers: modifiedHeaders, + headers: addContentTypeToHeaders(headers, body), queryParameters: queryParameters, apiName: apiName, ); diff --git a/packages/api/amplify_api/lib/src/util.dart b/packages/api/amplify_api/lib/src/util.dart new file mode 100644 index 0000000000..d91d58ce48 --- /dev/null +++ b/packages/api/amplify_api/lib/src/util.dart @@ -0,0 +1,32 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'package:amplify_core/amplify_core.dart'; +import 'package:meta/meta.dart'; + +/// Sets the 'Content-Type' of headers to match the [HttpPayload] body. +@internal +Map<String, String>? addContentTypeToHeaders( + Map<String, String>? headers, + HttpPayload? body, +) { + final contentType = body?.contentType; + if (contentType == null) { + return headers; + } + // Create new map to avoid modifying input headers which may be unmodifiable. + final modifiedHeaders = Map<String, String>.of(headers ?? {}); + modifiedHeaders.putIfAbsent(AWSHeaders.contentType, () => contentType); + return modifiedHeaders; +} diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index 2bc42e55cb..00041dcf57 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: collection: ^1.15.0 flutter: sdk: flutter + http: ^0.13.4 meta: ^1.7.0 plugin_platform_interface: ^2.0.0 diff --git a/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart b/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart new file mode 100644 index 0000000000..d8c5162377 --- /dev/null +++ b/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart @@ -0,0 +1,103 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +import 'dart:convert'; + +import 'package:amplify_api/amplify_api.dart'; +import 'package:amplify_api/src/api_plugin_impl.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:async/async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'test_data/fake_amplify_configuration.dart'; + +const _expectedRestResponseBody = '"Hello from Lambda!"'; +const _pathThatShouldFail = 'notHere'; + +class MockAmplifyAPI extends AmplifyAPIDart { + @override + http.Client getRestClient({String? apiName}) => MockClient((request) async { + if (request.body.isNotEmpty) { + expect(request.headers['Content-Type'], 'application/json'); + } + if (request.url.path.contains(_pathThatShouldFail)) { + return http.Response('Not found', 404); + } + return http.Response(_expectedRestResponseBody, 200); + }); +} + +void main() { + late AmplifyAPI api; + + setUpAll(() async { + await Amplify.addPlugin(MockAmplifyAPI()); + await Amplify.configure(amplifyconfig); + }); + group('REST API', () { + Future<void> _verifyRestOperation( + CancelableOperation<AWSStreamedHttpResponse> operation, + ) async { + final response = + await operation.value.timeout(const Duration(seconds: 3)); + final body = await response.decodeBody(); + expect(body, _expectedRestResponseBody); + expect(response.statusCode, 200); + } + + test('get() should get 200', () async { + final operation = Amplify.API.get('items'); + await _verifyRestOperation(operation); + }); + + test('head() should get 200', () async { + final operation = Amplify.API.head('items'); + final response = await operation.value; + expect(response.statusCode, 200); + }); + + test('patch() should get 200', () async { + final operation = Amplify.API + .patch('items', body: HttpPayload.json({'name': 'Mow the lawn'})); + await _verifyRestOperation(operation); + }); + + test('post() should get 200', () async { + final operation = Amplify.API + .post('items', body: HttpPayload.json({'name': 'Mow the lawn'})); + await _verifyRestOperation(operation); + }); + + test('put() should get 200', () async { + final operation = Amplify.API + .put('items', body: HttpPayload.json({'name': 'Mow the lawn'})); + await _verifyRestOperation(operation); + }); + + test('delete() should get 200', () async { + final operation = Amplify.API + .delete('items', body: HttpPayload.json({'name': 'Mow the lawn'})); + await _verifyRestOperation(operation); + }); + + test('canceled request should never resolve', () async { + final operation = Amplify.API.get('items'); + operation.cancel(); + operation.then((p0) => fail('Request should have been cancelled.')); + await operation.valueOrCancellation(); + expect(operation.isCanceled, isTrue); + }); + }); +} diff --git a/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart b/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart new file mode 100644 index 0000000000..7b8fd53be0 --- /dev/null +++ b/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart @@ -0,0 +1,79 @@ +const amplifyconfig = '''{ + "UserAgent": "aws-amplify-cli/2.0", + "Version": "1.0", + "api": { + "plugins": { + "awsAPIPlugin": { + "apiIntegrationTestGraphQL": { + "endpointType": "GraphQL", + "endpoint": "https://abc123.appsync-api.us-east-1.amazonaws.com/graphql", + "region": "us-east-1", + "authorizationType": "API_KEY", + "apiKey": "abc123" + }, + "api123": { + "endpointType": "REST", + "endpoint": "https://abc123.execute-api.us-east-1.amazonaws.com/test", + "region": "us-east-1", + "authorizationType": "AWS_IAM" + } + } + } + }, + "auth": { + "plugins": { + "awsCognitoAuthPlugin": { + "UserAgent": "aws-amplify-cli/0.1.0", + "Version": "0.1.0", + "IdentityManager": { + "Default": {} + }, + "AppSync": { + "Default": { + "ApiUrl": "https://abc123.appsync-api.us-east-1.amazonaws.com/graphql", + "Region": "us-east-1", + "AuthMode": "API_KEY", + "ApiKey": "abc123", + "ClientDatabasePrefix": "apiIntegrationTestGraphQL_API_KEY" + } + }, + "CredentialsProvider": { + "CognitoIdentity": { + "Default": { + "PoolId": "us-east-1:abc123", + "Region": "us-east-1" + } + } + }, + "CognitoUserPool": { + "Default": { + "PoolId": "us-east-1_abc123", + "AppClientId": "abc123", + "Region": "us-east-1" + } + }, + "Auth": { + "Default": { + "authenticationFlowType": "USER_SRP_AUTH", + "socialProviders": [], + "usernameAttributes": [], + "signupAttributes": [ + "EMAIL" + ], + "passwordProtectionSettings": { + "passwordPolicyMinLength": 8, + "passwordPolicyCharacters": [] + }, + "mfaConfiguration": "OFF", + "mfaTypes": [ + "SMS" + ], + "verificationMechanisms": [ + "EMAIL" + ] + } + } + } + } + } +}'''; diff --git a/packages/api/amplify_api/test/util_test.dart b/packages/api/amplify_api/test/util_test.dart new file mode 100644 index 0000000000..062b8f7276 --- /dev/null +++ b/packages/api/amplify_api/test/util_test.dart @@ -0,0 +1,42 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. + +import 'package:amplify_api/src/util.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('addContentTypeToHeaders', () { + test('adds Content-Type header from payload', () { + final resultHeaders = addContentTypeToHeaders( + null, HttpPayload.json({'name': 'now the lawn'})); + expect(resultHeaders?['Content-Type'], 'application/json'); + }); + + test('no-op when payload null', () { + final inputHeaders = {'foo': 'bar'}; + final resultHeaders = addContentTypeToHeaders(inputHeaders, null); + expect(resultHeaders, inputHeaders); + }); + + test('does not change input headers', () { + final inputHeaders = {'foo': 'bar'}; + final resultHeaders = addContentTypeToHeaders( + inputHeaders, HttpPayload.json({'name': 'now the lawn'})); + expect(resultHeaders?['Content-Type'], 'application/json'); + expect(inputHeaders['Content-Type'], isNull); + }); + }); +} From e3c9cf6b665dc288ca170bb3a2c2a6ba0a390c63 Mon Sep 17 00:00:00 2001 From: Elijah Quartey <Equartey@users.noreply.github.com> Date: Wed, 13 Jul 2022 15:27:11 -0500 Subject: [PATCH 05/33] feat!(api): GraphQL API key auth mode (#1858) * feat(api): GraphQL API key auth mode * BREAKING CHANGE: GraphQL response errors now nullable --- .../types/api/graphql/graphql_response.dart | 9 +- packages/api/amplify_api/LICENSE | 29 ++- .../amplify/amplify_api/MainActivityTest.kt | 16 ++ .../integration_test/graphql_tests.dart | 8 +- .../provision_integration_test_resources.sh | 14 ++ .../lib/src/amplify_api_config.dart | 3 +- .../amplify_authorization_rest_client.dart | 14 +- .../amplify_api/lib/src/api_plugin_impl.dart | 48 +++- .../src/graphql/graphql_response_decoder.dart | 2 +- .../src/graphql/model_mutations_factory.dart | 14 ++ .../lib/src/graphql/send_graphql_request.dart | 57 +++++ .../lib/src/method_channel_api.dart | 17 +- packages/api/amplify_api/lib/src/util.dart | 18 ++ .../test/amplify_api_config_test.dart | 89 +++++++ .../amplify_api/test/dart_graphql_test.dart | 229 ++++++++++++++++++ .../amplify_api/test/graphql_error_test.dart | 2 +- .../query_predicate_graphql_filter_test.dart | 14 ++ .../test_data/fake_amplify_configuration.dart | 14 ++ 18 files changed, 568 insertions(+), 29 deletions(-) create mode 100644 packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart create mode 100644 packages/api/amplify_api/test/amplify_api_config_test.dart create mode 100644 packages/api/amplify_api/test/dart_graphql_test.dart diff --git a/packages/amplify_core/lib/src/types/api/graphql/graphql_response.dart b/packages/amplify_core/lib/src/types/api/graphql/graphql_response.dart index 8a7580fd7d..dc8d2345d6 100644 --- a/packages/amplify_core/lib/src/types/api/graphql/graphql_response.dart +++ b/packages/amplify_core/lib/src/types/api/graphql/graphql_response.dart @@ -22,8 +22,8 @@ class GraphQLResponse<T> { /// This will be `null` if there are any GraphQL errors during execution. final T? data; - /// A list of errors from execution. If no errors, it will be an empty list. - final List<GraphQLResponseError> errors; + /// A list of errors from execution. If no errors, it will be `null`. + final List<GraphQLResponseError>? errors; const GraphQLResponse({ this.data, @@ -36,7 +36,10 @@ class GraphQLResponse<T> { }) { return GraphQLResponse( data: data, - errors: errors ?? const [], + errors: errors, ); } + + // Returns true when errors are present and not empty. + bool get hasErrors => errors != null && errors!.isNotEmpty; } diff --git a/packages/api/amplify_api/LICENSE b/packages/api/amplify_api/LICENSE index 19dc35b243..d645695673 100644 --- a/packages/api/amplify_api/LICENSE +++ b/packages/api/amplify_api/LICENSE @@ -172,4 +172,31 @@ of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. \ No newline at end of file + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/packages/api/amplify_api/example/android/app/src/androidTest/java/com/amazonaws/amplify/amplify_api/MainActivityTest.kt b/packages/api/amplify_api/example/android/app/src/androidTest/java/com/amazonaws/amplify/amplify_api/MainActivityTest.kt index 6f677739be..8b9960a876 100644 --- a/packages/api/amplify_api/example/android/app/src/androidTest/java/com/amazonaws/amplify/amplify_api/MainActivityTest.kt +++ b/packages/api/amplify_api/example/android/app/src/androidTest/java/com/amazonaws/amplify/amplify_api/MainActivityTest.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + */ + package com.amazonaws.amplify.amplify_api_example import androidx.test.rule.ActivityTestRule diff --git a/packages/api/amplify_api/example/integration_test/graphql_tests.dart b/packages/api/amplify_api/example/integration_test/graphql_tests.dart index d632e2ef14..f1a9a42362 100644 --- a/packages/api/amplify_api/example/integration_test/graphql_tests.dart +++ b/packages/api/amplify_api/example/integration_test/graphql_tests.dart @@ -44,7 +44,7 @@ void main() { final req = GraphQLRequest<String>( document: graphQLDocument, variables: <String, String>{'id': id}); final response = await Amplify.API.mutate(request: req).response; - if (response.errors.isNotEmpty) { + if (response.hasErrors) { fail( 'GraphQL error while deleting a blog: ${response.errors.toString()}'); } @@ -561,7 +561,7 @@ void main() { // With stream established, exec callback with stream events. final subscription = await _getEstablishedSubscriptionOperation<T>( subscriptionRequest, (event) { - if (event.errors.isNotEmpty) { + if (event.hasErrors) { fail('subscription errors: ${event.errors}'); } dataCompleter.complete(event); @@ -657,6 +657,8 @@ void main() { expect(postFromEvent?.title, equals(title)); }); - }); + }, + skip: + 'TODO(ragingsquirrel3): re-enable tests once subscriptions are implemented.'); }); } diff --git a/packages/api/amplify_api/example/tool/provision_integration_test_resources.sh b/packages/api/amplify_api/example/tool/provision_integration_test_resources.sh index 072ebabbda..d74e2dc37d 100755 --- a/packages/api/amplify_api/example/tool/provision_integration_test_resources.sh +++ b/packages/api/amplify_api/example/tool/provision_integration_test_resources.sh @@ -1,4 +1,18 @@ #!/bin/bash +# Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + set -e IFS='|' diff --git a/packages/api/amplify_api/lib/src/amplify_api_config.dart b/packages/api/amplify_api/lib/src/amplify_api_config.dart index 960f11bf9b..4d4c21e9fa 100644 --- a/packages/api/amplify_api/lib/src/amplify_api_config.dart +++ b/packages/api/amplify_api/lib/src/amplify_api_config.dart @@ -29,7 +29,8 @@ class EndpointConfig with AWSEquatable<EndpointConfig> { /// Gets the host with environment path prefix from Amplify config and combines /// with [path] and [queryParameters] to return a full [Uri]. - Uri getUri(String path, Map<String, dynamic>? queryParameters) { + Uri getUri({String? path, Map<String, dynamic>? queryParameters}) { + path = path ?? ''; final parsed = Uri.parse(config.endpoint); // Remove leading slashes which are suggested in public documentation. // https://docs.amplify.aws/lib/restapi/getting-started/q/platform/flutter/#make-a-post-request diff --git a/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart index e58885385c..8a2d0678b5 100644 --- a/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart +++ b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart @@ -18,6 +18,8 @@ import 'package:amplify_core/amplify_core.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; +const _xApiKey = 'X-Api-Key'; + /// Implementation of http [http.Client] that authorizes HTTP requests with /// Amplify. @internal @@ -50,8 +52,16 @@ class AmplifyAuthorizationRestClient extends http.BaseClient http.BaseRequest _authorizeRequest(http.BaseRequest request) { if (!request.headers.containsKey(AWSHeaders.authorization) && endpointConfig.authorizationType != APIAuthorizationType.none) { - // ignore: todo - // TODO: Use auth providers from core to transform the request. + // TODO(ragingsquirrel3): Use auth providers from core to transform the request. + final apiKey = endpointConfig.apiKey; + if (endpointConfig.authorizationType == APIAuthorizationType.apiKey) { + if (apiKey == null) { + throw const ApiException( + 'Auth mode is API Key, but no API Key was found in config.'); + } + + request.headers.putIfAbsent(_xApiKey, () => apiKey); + } } return request; } diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index 0926c0a462..a54ad5ee2b 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -26,6 +26,7 @@ import 'package:meta/meta.dart'; import 'amplify_api_config.dart'; import 'amplify_authorization_rest_client.dart'; +import 'graphql/send_graphql_request.dart'; import 'util.dart'; /// {@template amplify_api.amplify_api_dart} @@ -85,6 +86,19 @@ class AmplifyAPIDart extends AmplifyAPI { } } + /// Returns the HTTP client to be used for GraphQL operations. + /// + /// Use [apiName] if there are multiple GraphQL endpoints. + @visibleForTesting + http.Client getGraphQLClient({String? apiName}) { + final endpoint = _apiConfig.getEndpoint( + type: EndpointType.graphQL, + apiName: apiName, + ); + return _clientPool[endpoint.name] ??= AmplifyAuthorizationRestClient( + endpointConfig: endpoint.config, baseClient: _baseHttpClient); + } + /// Returns the HTTP client to be used for REST operations. /// /// Use [apiName] if there are multiple REST endpoints. @@ -100,13 +114,21 @@ class AmplifyAPIDart extends AmplifyAPI { ); } + Uri _getGraphQLUri(String? apiName) { + final endpoint = _apiConfig.getEndpoint( + type: EndpointType.graphQL, + apiName: apiName, + ); + return endpoint.getUri(path: null, queryParameters: null); + } + Uri _getRestUri( String path, String? apiName, Map<String, dynamic>? queryParameters) { final endpoint = _apiConfig.getEndpoint( type: EndpointType.rest, apiName: apiName, ); - return endpoint.getUri(path, queryParameters); + return endpoint.getUri(path: path, queryParameters: queryParameters); } /// NOTE: http does not support request abort https://github.com/dart-lang/http/issues/424. @@ -130,6 +152,30 @@ class AmplifyAPIDart extends AmplifyAPI { _authProviders[authProvider.type] = authProvider; } + // ====== GraphQL ====== + + @override + CancelableOperation<GraphQLResponse<T>> query<T>( + {required GraphQLRequest<T> request}) { + final graphQLClient = getGraphQLClient(apiName: request.apiName); + final uri = _getGraphQLUri(request.apiName); + + final responseFuture = sendGraphQLRequest<T>( + request: request, client: graphQLClient, uri: uri); + return _makeCancelable<GraphQLResponse<T>>(responseFuture); + } + + @override + CancelableOperation<GraphQLResponse<T>> mutate<T>( + {required GraphQLRequest<T> request}) { + final graphQLClient = getGraphQLClient(apiName: request.apiName); + final uri = _getGraphQLUri(request.apiName); + + final responseFuture = sendGraphQLRequest<T>( + request: request, client: graphQLClient, uri: uri); + return _makeCancelable<GraphQLResponse<T>>(responseFuture); + } + // ====== REST ======= @override diff --git a/packages/api/amplify_api/lib/src/graphql/graphql_response_decoder.dart b/packages/api/amplify_api/lib/src/graphql/graphql_response_decoder.dart index 3a66a4cafb..ec77157480 100644 --- a/packages/api/amplify_api/lib/src/graphql/graphql_response_decoder.dart +++ b/packages/api/amplify_api/lib/src/graphql/graphql_response_decoder.dart @@ -34,7 +34,7 @@ class GraphQLResponseDecoder { GraphQLResponse<T> decode<T>( {required GraphQLRequest request, String? data, - required List<GraphQLResponseError> errors}) { + List<GraphQLResponseError>? errors}) { if (data == null) { return GraphQLResponse(data: null, errors: errors); } diff --git a/packages/api/amplify_api/lib/src/graphql/model_mutations_factory.dart b/packages/api/amplify_api/lib/src/graphql/model_mutations_factory.dart index c0c2a4927a..1793cbee49 100644 --- a/packages/api/amplify_api/lib/src/graphql/model_mutations_factory.dart +++ b/packages/api/amplify_api/lib/src/graphql/model_mutations_factory.dart @@ -1,3 +1,17 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + import 'package:amplify_api/src/graphql/graphql_request_factory.dart'; import 'package:amplify_core/amplify_core.dart'; diff --git a/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart b/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart new file mode 100644 index 0000000000..6eab7deadd --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart @@ -0,0 +1,57 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +import 'dart:convert'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +import '../util.dart'; +import 'graphql_response_decoder.dart'; + +/// Converts the [GraphQLRequest] to an HTTP POST request and sends with ///[client]. +@internal +Future<GraphQLResponse<T>> sendGraphQLRequest<T>({ + required GraphQLRequest<T> request, + required http.Client client, + required Uri uri, +}) async { + try { + final body = {'variables': request.variables, 'query': request.document}; + final graphQLResponse = await client.post(uri, body: json.encode(body)); + + final responseBody = json.decode(graphQLResponse.body); + + if (responseBody is! Map<String, dynamic>) { + throw ApiException( + 'unable to parse GraphQLResponse from server response which was not a JSON object.', + underlyingException: graphQLResponse.body); + } + + final responseData = responseBody['data']; + // Preserve `null`. json.encode(null) returns "null" not `null` + final responseDataJson = + responseData != null ? json.encode(responseData) : null; + + final errors = deserializeGraphQLResponseErrors(responseBody); + + return GraphQLResponseDecoder.instance + .decode<T>(request: request, data: responseDataJson, errors: errors); + } on Exception catch (e) { + throw ApiException('unable to send GraphQLRequest to client.', + underlyingException: e.toString()); + } +} diff --git a/packages/api/amplify_api/lib/src/method_channel_api.dart b/packages/api/amplify_api/lib/src/method_channel_api.dart index c0285a6305..45f5eb862f 100644 --- a/packages/api/amplify_api/lib/src/method_channel_api.dart +++ b/packages/api/amplify_api/lib/src/method_channel_api.dart @@ -207,7 +207,7 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { AmplifyExceptionMessages.nullReturnedFromMethodChannel, ); } - final errors = _deserializeGraphQLResponseErrors(result); + final errors = deserializeGraphQLResponseErrors(result); GraphQLResponse<T> response = GraphQLResponseDecoder.instance.decode<T>( request: request, data: result['data'] as String?, errors: errors); @@ -466,19 +466,4 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { ); } } - - List<GraphQLResponseError> _deserializeGraphQLResponseErrors( - Map<String, dynamic> response, - ) { - final errors = response['errors'] as List?; - if (errors == null || errors.isEmpty) { - return const []; - } - return errors - .cast<Map>() - .map((message) => GraphQLResponseError.fromJson( - message.cast<String, dynamic>(), - )) - .toList(); - } } diff --git a/packages/api/amplify_api/lib/src/util.dart b/packages/api/amplify_api/lib/src/util.dart index d91d58ce48..2d28b59afc 100644 --- a/packages/api/amplify_api/lib/src/util.dart +++ b/packages/api/amplify_api/lib/src/util.dart @@ -30,3 +30,21 @@ Map<String, String>? addContentTypeToHeaders( modifiedHeaders.putIfAbsent(AWSHeaders.contentType, () => contentType); return modifiedHeaders; } + +/// Grabs errors from GraphQL Response. Is used in method channel and Dart first code. +/// TODO(Equartey): Move to Dart first code when method channel GraphQL implementation is removed. +@internal +List<GraphQLResponseError>? deserializeGraphQLResponseErrors( + Map<String, dynamic> response, +) { + final errors = response['errors'] as List?; + if (errors == null || errors.isEmpty) { + return null; + } + return errors + .cast<Map>() + .map((message) => GraphQLResponseError.fromJson( + message.cast<String, dynamic>(), + )) + .toList(); +} diff --git a/packages/api/amplify_api/test/amplify_api_config_test.dart b/packages/api/amplify_api/test/amplify_api_config_test.dart new file mode 100644 index 0000000000..5168adfa04 --- /dev/null +++ b/packages/api/amplify_api/test/amplify_api_config_test.dart @@ -0,0 +1,89 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:convert'; + +import 'package:amplify_api/src/amplify_api_config.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'test_data/fake_amplify_configuration.dart'; + +void main() { + late EndpointConfig endpointConfig; + + group('GraphQL Config', () { + const endpointType = EndpointType.graphQL; + const endpoint = + 'https://abc123.appsync-api.us-east-1.amazonaws.com/graphql'; + const region = 'us-east-1'; + const authorizationType = APIAuthorizationType.apiKey; + const apiKey = 'abc-123'; + + setUpAll(() async { + const config = AWSApiConfig( + endpointType: endpointType, + endpoint: endpoint, + region: region, + authorizationType: authorizationType, + apiKey: apiKey); + + endpointConfig = const EndpointConfig('GraphQL', config); + }); + + test('should return valid URI with null params', () async { + final uri = endpointConfig.getUri(); + final expected = Uri.parse('$endpoint/'); + + expect(uri, equals(expected)); + }); + }); + + group('REST Config', () { + const endpointType = EndpointType.rest; + const endpoint = 'https://abc123.appsync-api.us-east-1.amazonaws.com/test'; + const region = 'us-east-1'; + const authorizationType = APIAuthorizationType.iam; + + setUpAll(() async { + const config = AWSApiConfig( + endpointType: endpointType, + endpoint: endpoint, + region: region, + authorizationType: authorizationType); + + endpointConfig = const EndpointConfig('REST', config); + }); + + test('should return valid URI with params', () async { + final path = 'path/to/nowhere'; + final params = {'foo': 'bar', 'bar': 'baz'}; + final uri = endpointConfig.getUri(path: path, queryParameters: params); + + final expected = Uri.parse('$endpoint/$path?foo=bar&bar=baz'); + + expect(uri, equals(expected)); + }); + + test('should handle a leading slash', () async { + final path = '/path/to/nowhere'; + final params = {'foo': 'bar', 'bar': 'baz'}; + final uri = endpointConfig.getUri(path: path, queryParameters: params); + + final expected = Uri.parse('$endpoint$path?foo=bar&bar=baz'); + + expect(uri, equals(expected)); + }); + }); +} diff --git a/packages/api/amplify_api/test/dart_graphql_test.dart b/packages/api/amplify_api/test/dart_graphql_test.dart new file mode 100644 index 0000000000..bedd0092f2 --- /dev/null +++ b/packages/api/amplify_api/test/dart_graphql_test.dart @@ -0,0 +1,229 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:convert'; + +import 'package:amplify_api/amplify_api.dart'; +import 'package:amplify_api/src/api_plugin_impl.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:amplify_test/test_models/ModelProvider.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'test_data/fake_amplify_configuration.dart'; + +final _deepEquals = const DeepCollectionEquality().equals; + +// Success Mocks +const _expectedQuerySuccessResponseBody = { + 'data': { + 'listBlogs': { + 'items': [ + { + 'id': 'TEST_ID', + 'name': 'Test App Blog', + 'createdAt': '2022-06-28T17:36:52.460Z' + } + ] + } + } +}; + +final _modelQueryId = uuid(); +final _expectedModelQueryResult = { + 'data': { + 'getBlog': { + 'createdAt': '2021-07-21T22:23:33.707Z', + 'id': _modelQueryId, + 'name': 'Test App Blog' + } + } +}; +const _expectedMutateSuccessResponseBody = { + 'data': { + 'createBlog': { + 'id': 'TEST_ID', + 'name': 'Test App Blog', + 'createdAt': '2022-07-06T18:42:26.126Z' + } + } +}; + +// Error Mocks +const _errorMessage = 'Unable to parse GraphQL query.'; +const _errorLocations = [ + {'line': 2, 'column': 3}, + {'line': 4, 'column': 5} +]; +const _errorPath = ['a', 1, 'b']; +const _errorExtensions = { + 'a': 'blah', + 'b': {'c': 'd'} +}; +const _expectedErrorResponseBody = { + 'data': null, + 'errors': [ + { + 'message': _errorMessage, + 'locations': _errorLocations, + 'path': _errorPath, + 'extensions': _errorExtensions, + }, + ] +}; + +class MockAmplifyAPI extends AmplifyAPIDart { + MockAmplifyAPI({ + ModelProviderInterface? modelProvider, + }) : super(modelProvider: modelProvider); + + @override + http.Client getGraphQLClient({String? apiName}) => + MockClient((request) async { + if (request.body.contains('getBlog')) { + return http.Response(json.encode(_expectedModelQueryResult), 200); + } + if (request.body.contains('TestMutate')) { + return http.Response( + json.encode(_expectedMutateSuccessResponseBody), 400); + } + if (request.body.contains('TestError')) { + return http.Response(json.encode(_expectedErrorResponseBody), 400); + } + + return http.Response( + json.encode(_expectedQuerySuccessResponseBody), 200); + }); +} + +void main() { + setUpAll(() async { + await Amplify.addPlugin(MockAmplifyAPI( + modelProvider: ModelProvider.instance, + )); + await Amplify.configure(amplifyconfig); + }); + group('Vanilla GraphQL', () { + test('Query returns proper response.data', () async { + String graphQLDocument = ''' query TestQuery { + listBlogs { + items { + id + name + createdAt + } + } + } '''; + final req = GraphQLRequest(document: graphQLDocument, variables: {}); + + final operation = Amplify.API.query(request: req); + final res = await operation.value; + + final expected = json.encode(_expectedQuerySuccessResponseBody['data']); + + expect(res.data, equals(expected)); + expect(res.errors, equals(null)); + }); + + test('Mutate returns proper response.data', () async { + String graphQLDocument = ''' mutation TestMutate(\$name: String!) { + createBlog(input: {name: \$name}) { + id + name + createdAt + } + } '''; + final graphQLVariables = {'name': 'Test Blog 1'}; + final req = GraphQLRequest( + document: graphQLDocument, variables: graphQLVariables); + + final operation = Amplify.API.mutate(request: req); + final res = await operation.value; + + final expected = json.encode(_expectedMutateSuccessResponseBody['data']); + + expect(res.data, equals(expected)); + expect(res.errors, equals(null)); + }); + }); + group('Model Helpers', () { + const blogSelectionSet = + 'id name createdAt file { bucket region key meta { name } } files { bucket region key meta { name } } updatedAt'; + + test('Query returns proper response.data for Models', () async { + const expectedDoc = + 'query getBlog(\$id: ID!) { getBlog(id: \$id) { $blogSelectionSet } }'; + const decodePath = 'getBlog'; + + GraphQLRequest<Blog> req = + ModelQueries.get<Blog>(Blog.classType, _modelQueryId); + + final operation = Amplify.API.query(request: req); + final res = await operation.value; + + // request asserts + expect(req.document, expectedDoc); + expect(_deepEquals(req.variables, {'id': _modelQueryId}), isTrue); + expect(req.modelType, Blog.classType); + expect(req.decodePath, decodePath); + // response asserts + expect(res.data, isA<Blog>()); + expect(res.data?.id, _modelQueryId); + expect(res.errors, equals(null)); + }); + }); + + group('Error Handling', () { + test('response errors are decoded', () async { + String graphQLDocument = ''' TestError '''; + final req = GraphQLRequest(document: graphQLDocument, variables: {}); + + final operation = Amplify.API.query(request: req); + final res = await operation.value; + + const errorExpected = GraphQLResponseError( + message: _errorMessage, + locations: [ + GraphQLResponseErrorLocation(2, 3), + GraphQLResponseErrorLocation(4, 5), + ], + path: <dynamic>[..._errorPath], + extensions: <String, dynamic>{..._errorExtensions}, + ); + + expect(res.data, equals(null)); + expect(res.errors?.single, equals(errorExpected)); + }); + + test('canceled query request should never resolve', () async { + final req = GraphQLRequest(document: '', variables: {}); + final operation = Amplify.API.query(request: req); + operation.cancel(); + operation.then((p0) => fail('Request should have been cancelled.')); + await operation.valueOrCancellation(); + expect(operation.isCanceled, isTrue); + }); + + test('canceled mutation request should never resolve', () async { + final req = GraphQLRequest(document: '', variables: {}); + final operation = Amplify.API.mutate(request: req); + operation.cancel(); + operation.then((p0) => fail('Request should have been cancelled.')); + await operation.valueOrCancellation(); + expect(operation.isCanceled, isTrue); + }); + }); +} diff --git a/packages/api/amplify_api/test/graphql_error_test.dart b/packages/api/amplify_api/test/graphql_error_test.dart index ee6588691a..32752299ee 100644 --- a/packages/api/amplify_api/test/graphql_error_test.dart +++ b/packages/api/amplify_api/test/graphql_error_test.dart @@ -68,6 +68,6 @@ void main() { .response; expect(resp.data, equals(null)); - expect(resp.errors.single, equals(expected)); + expect(resp.errors?.single, equals(expected)); }); } diff --git a/packages/api/amplify_api/test/query_predicate_graphql_filter_test.dart b/packages/api/amplify_api/test/query_predicate_graphql_filter_test.dart index acf8cf18a8..850fd5e1a4 100644 --- a/packages/api/amplify_api/test/query_predicate_graphql_filter_test.dart +++ b/packages/api/amplify_api/test/query_predicate_graphql_filter_test.dart @@ -1,3 +1,17 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + import 'package:amplify_api/src/graphql/graphql_request_factory.dart'; import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:amplify_flutter/src/amplify_impl.dart'; diff --git a/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart b/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart index 7b8fd53be0..0b3c0dae01 100644 --- a/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart +++ b/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart @@ -1,3 +1,17 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + const amplifyconfig = '''{ "UserAgent": "aws-amplify-cli/2.0", "Version": "1.0", From 18e427278213451093510ed113de2db566447e38 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Tue, 19 Jul 2022 08:42:49 -0800 Subject: [PATCH 06/33] feat!(core,auth): auth providers definition and CognitoIamAuthProvider registers in Auth (#1851) --- .../amplify_flutter/lib/src/hybrid_impl.dart | 3 +- packages/amplify_core/lib/amplify_core.dart | 3 + .../lib/src/amplify_class_impl.dart | 8 +- .../src/plugin/amplify_plugin_interface.dart | 5 +- .../api/auth/api_authorization_type.dart | 18 ++- .../types/common/amplify_auth_provider.dart | 79 +++++++++++ packages/amplify_core/pubspec.yaml | 2 +- .../test/amplify_auth_provider_test.dart | 132 ++++++++++++++++++ .../amplify_api/lib/src/api_plugin_impl.dart | 5 +- .../lib/src/auth_plugin_impl.dart | 14 +- .../src/util/cognito_iam_auth_provider.dart | 83 +++++++++++ .../test/plugin/auth_providers_test.dart | 112 +++++++++++++++ .../test/plugin/delete_user_test.dart | 17 ++- .../test/plugin/sign_out_test.dart | 52 +++++-- 14 files changed, 507 insertions(+), 26 deletions(-) create mode 100644 packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart create mode 100644 packages/amplify_core/test/amplify_auth_provider_test.dart create mode 100644 packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_iam_auth_provider.dart create mode 100644 packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart diff --git a/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart b/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart index 5eb3f1257e..8c166f03f6 100644 --- a/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart +++ b/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart @@ -36,7 +36,8 @@ class AmplifyHybridImpl extends AmplifyClassImpl { [ ...API.plugins, ...Auth.plugins, - ].map((p) => p.configure(config: amplifyConfig)), + ].map((p) => p.configure( + config: amplifyConfig, authProviderRepo: authProviderRepo)), eagerError: true, ); await _methodChannelAmplify.configurePlatform(config); diff --git a/packages/amplify_core/lib/amplify_core.dart b/packages/amplify_core/lib/amplify_core.dart index c2bf72c5b1..787626b3c0 100644 --- a/packages/amplify_core/lib/amplify_core.dart +++ b/packages/amplify_core/lib/amplify_core.dart @@ -77,6 +77,9 @@ export 'src/types/api/api_types.dart'; /// Auth export 'src/types/auth/auth_types.dart'; +/// Auth providers +export 'src/types/common/amplify_auth_provider.dart'; + /// Datastore export 'src/types/datastore/datastore_types.dart' hide DateTimeParse; diff --git a/packages/amplify_core/lib/src/amplify_class_impl.dart b/packages/amplify_core/lib/src/amplify_class_impl.dart index d802d4a69d..00c9cba346 100644 --- a/packages/amplify_core/lib/src/amplify_class_impl.dart +++ b/packages/amplify_core/lib/src/amplify_class_impl.dart @@ -24,6 +24,11 @@ import 'package:meta/meta.dart'; /// {@endtemplate} @internal class AmplifyClassImpl extends AmplifyClass { + /// Share AmplifyAuthProviders with plugins. + @protected + final AmplifyAuthProviderRepository authProviderRepo = + AmplifyAuthProviderRepository(); + /// {@macro amplify_flutter.amplify_class_impl} AmplifyClassImpl() : super.protected(); @@ -57,7 +62,8 @@ class AmplifyClassImpl extends AmplifyClass { ...Auth.plugins, ...DataStore.plugins, ...Storage.plugins, - ].map((p) => p.configure(config: amplifyConfig)), + ].map((p) => p.configure( + config: amplifyConfig, authProviderRepo: authProviderRepo)), eagerError: true, ); } diff --git a/packages/amplify_core/lib/src/plugin/amplify_plugin_interface.dart b/packages/amplify_core/lib/src/plugin/amplify_plugin_interface.dart index 821c6fe38e..4ca5f7c2a1 100644 --- a/packages/amplify_core/lib/src/plugin/amplify_plugin_interface.dart +++ b/packages/amplify_core/lib/src/plugin/amplify_plugin_interface.dart @@ -30,7 +30,10 @@ abstract class AmplifyPluginInterface { Future<void> addPlugin() async {} /// Configures the plugin using the registered [config]. - Future<void> configure({AmplifyConfig? config}) async {} + Future<void> configure({ + AmplifyConfig? config, + required AmplifyAuthProviderRepository authProviderRepo, + }) async {} /// Resets the plugin by removing all traces of it from the device. @visibleForTesting diff --git a/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart b/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart index e81ef856f4..f15da13b9f 100644 --- a/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart +++ b/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart @@ -13,6 +13,7 @@ * permissions and limitations under the License. */ +import 'package:amplify_core/src/types/common/amplify_auth_provider.dart'; import 'package:collection/collection.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -24,17 +25,17 @@ part 'api_authorization_type.g.dart'; /// See also: /// - [AppSync Security](https://docs.aws.amazon.com/appsync/latest/devguide/security.html) @JsonEnum(alwaysCreate: true) -enum APIAuthorizationType { +enum APIAuthorizationType<T extends AmplifyAuthProvider> { /// For public APIs. @JsonValue('NONE') - none, + none(AmplifyAuthProviderToken<AmplifyAuthProvider>()), /// A hardcoded key which can provide throttling for an unauthenticated API. /// /// See also: /// - [API Key Authorization](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#api-key-authorization) @JsonValue('API_KEY') - apiKey, + apiKey(AmplifyAuthProviderToken<AmplifyAuthProvider>()), /// Use an IAM access/secret key credential pair to authorize access to an API. /// @@ -42,7 +43,7 @@ enum APIAuthorizationType { /// - [IAM Authorization](https://docs.aws.amazon.com/appsync/latest/devguide/security.html#aws-iam-authorization) /// - [IAM Introduction](https://docs.aws.amazon.com/IAM/latest/UserGuide/introduction.html) @JsonValue('AWS_IAM') - iam, + iam(AmplifyAuthProviderToken<AWSIamAmplifyAuthProvider>()), /// OpenID Connect is a simple identity layer on top of OAuth2.0. /// @@ -50,21 +51,24 @@ enum APIAuthorizationType { /// - [OpenID Connect Authorization](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#openid-connect-authorization) /// - [OpenID Connect Specification](https://openid.net/specs/openid-connect-core-1_0.html) @JsonValue('OPENID_CONNECT') - oidc, + oidc(AmplifyAuthProviderToken<TokenAmplifyAuthProvider>()), /// Control access to date by putting users into different permissions pools. /// /// See also: /// - [Amazon Cognito User Pools](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#amazon-cognito-user-pools-authorization) @JsonValue('AMAZON_COGNITO_USER_POOLS') - userPools, + userPools(AmplifyAuthProviderToken<TokenAmplifyAuthProvider>()), /// Control access by calling a lambda function. /// /// See also: /// - [Introducing Lambda authorization for AWS AppSync GraphQL APIs](https://aws.amazon.com/blogs/mobile/appsync-lambda-auth/) @JsonValue('AWS_LAMBDA') - function + function(AmplifyAuthProviderToken<TokenAmplifyAuthProvider>()); + + const APIAuthorizationType(this.authProviderToken); + final AmplifyAuthProviderToken<T> authProviderToken; } /// Helper methods for [APIAuthorizationType]. diff --git a/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart b/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart new file mode 100644 index 0000000000..30c00ff053 --- /dev/null +++ b/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart @@ -0,0 +1,79 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +import 'package:amplify_core/amplify_core.dart'; +import 'package:aws_signature_v4/aws_signature_v4.dart'; + +/// An identifier to use as a key in an [AmplifyAuthProviderRepository] so that +/// a retrieved auth provider can be typed more accurately. +class AmplifyAuthProviderToken<T extends AmplifyAuthProvider> extends Token<T> { + const AmplifyAuthProviderToken(); +} + +abstract class AuthProviderOptions { + const AuthProviderOptions(); +} + +/// Options required by IAM to sign any given request at runtime. +class IamAuthProviderOptions extends AuthProviderOptions { + final String region; + final AWSService service; + + const IamAuthProviderOptions({required this.region, required this.service}); +} + +abstract class AmplifyAuthProvider { + Future<AWSBaseHttpRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }); +} + +abstract class AWSIamAmplifyAuthProvider extends AmplifyAuthProvider + implements AWSCredentialsProvider { + @override + Future<AWSSignedRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant IamAuthProviderOptions options, + }); +} + +abstract class TokenAmplifyAuthProvider extends AmplifyAuthProvider { + Future<String> getLatestAuthToken(); + + @override + Future<AWSBaseHttpRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }) async { + final token = await getLatestAuthToken(); + request.headers.putIfAbsent(AWSHeaders.authorization, () => token); + return request; + } +} + +class AmplifyAuthProviderRepository { + final Map<AmplifyAuthProviderToken, AmplifyAuthProvider> _authProviders = {}; + + T? getAuthProvider<T extends AmplifyAuthProvider>( + AmplifyAuthProviderToken<T> token) { + return _authProviders[token] as T?; + } + + void registerAuthProvider<T extends AmplifyAuthProvider>( + AmplifyAuthProviderToken<T> token, AmplifyAuthProvider authProvider) { + _authProviders[token] = authProvider; + } +} diff --git a/packages/amplify_core/pubspec.yaml b/packages/amplify_core/pubspec.yaml index f3df87c4cb..0c9ca29999 100644 --- a/packages/amplify_core/pubspec.yaml +++ b/packages/amplify_core/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: aws_common: ^0.1.0 aws_signature_v4: ^0.1.0 collection: ^1.15.0 - http: ^0.13.0 + http: ^0.13.4 intl: ^0.17.0 json_annotation: ^4.4.0 logging: ^1.0.0 diff --git a/packages/amplify_core/test/amplify_auth_provider_test.dart b/packages/amplify_core/test/amplify_auth_provider_test.dart new file mode 100644 index 0000000000..08a0e06e4d --- /dev/null +++ b/packages/amplify_core/test/amplify_auth_provider_test.dart @@ -0,0 +1,132 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +import 'package:amplify_core/amplify_core.dart'; +import 'package:aws_signature_v4/aws_signature_v4.dart'; +import 'package:test/test.dart'; + +const _testAuthKey = 'TestAuthKey'; +const _testToken = 'abc123-fake-token'; + +AWSHttpRequest _generateTestRequest() { + return AWSHttpRequest( + method: AWSHttpMethod.get, + uri: Uri.parse('https://www.amazon.com'), + ); +} + +class TestAuthProvider extends AmplifyAuthProvider { + @override + Future<AWSBaseHttpRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }) async { + request.headers.putIfAbsent(_testAuthKey, () => 'foo'); + return request; + } +} + +class SecondTestAuthProvider extends AmplifyAuthProvider { + @override + Future<AWSBaseHttpRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }) async { + request.headers.putIfAbsent(_testAuthKey, () => 'bar'); + return request; + } +} + +class TestAWSCredentialsAuthProvider extends AWSIamAmplifyAuthProvider { + @override + Future<AWSCredentials> retrieve() async { + return const AWSCredentials( + 'fake-access-key-123', 'fake-secret-access-key-456'); + } + + @override + Future<AWSSignedRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant IamAuthProviderOptions? options, + }) async { + request.headers.putIfAbsent(_testAuthKey, () => 'foo'); + return request as AWSSignedRequest; + } +} + +class TestTokenProvider extends TokenAmplifyAuthProvider { + @override + Future<String> getLatestAuthToken() async { + return _testToken; + } +} + +void main() { + final authProvider = TestAuthProvider(); + + group('AmplifyAuthProvider', () { + test('can authorize an HTTP request', () async { + final authorizedRequest = + await authProvider.authorizeRequest(_generateTestRequest()); + expect(authorizedRequest.headers[_testAuthKey], 'foo'); + }); + }); + + group('TokenAmplifyAuthProvider', () { + test('will assign the token to the "Authorization" header', () async { + final tokenAuthProvider = TestTokenProvider(); + final authorizedRequest = + await tokenAuthProvider.authorizeRequest(_generateTestRequest()); + expect(authorizedRequest.headers[AWSHeaders.authorization], _testToken); + }); + }); + + group('AmplifyAuthProviderRepository', () { + test('can register a valid auth provider and use to retrieve', () async { + final authRepo = AmplifyAuthProviderRepository(); + + const providerKey = AmplifyAuthProviderToken(); + authRepo.registerAuthProvider(providerKey, authProvider); + final actualAuthProvider = authRepo.getAuthProvider(providerKey); + final authorizedRequest = + await actualAuthProvider!.authorizeRequest(_generateTestRequest()); + expect(authorizedRequest.headers[_testAuthKey], 'foo'); + }); + + test('will correctly type the retrieved auth provider', () async { + final authRepo = AmplifyAuthProviderRepository(); + + final credentialAuthProvider = TestAWSCredentialsAuthProvider(); + const providerKey = AmplifyAuthProviderToken<AWSIamAmplifyAuthProvider>(); + authRepo.registerAuthProvider(providerKey, credentialAuthProvider); + AWSIamAmplifyAuthProvider? actualAuthProvider = + authRepo.getAuthProvider(providerKey); + expect(actualAuthProvider, isA<AWSIamAmplifyAuthProvider>()); + }); + + test('will overwrite previous provider in same key', () async { + final authRepo = AmplifyAuthProviderRepository(); + + const providerKey = AmplifyAuthProviderToken(); + authRepo.registerAuthProvider(providerKey, authProvider); + authRepo.registerAuthProvider(providerKey, SecondTestAuthProvider()); + final actualAuthProvider = authRepo.getAuthProvider(providerKey); + + final authorizedRequest = + await actualAuthProvider!.authorizeRequest(_generateTestRequest()); + expect(authorizedRequest.headers[_testAuthKey], 'bar'); + }); + }); +} diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index a54ad5ee2b..a5dfd58ce6 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -54,7 +54,10 @@ class AmplifyAPIDart extends AmplifyAPI { } @override - Future<void> configure({AmplifyConfig? config}) async { + Future<void> configure({ + AmplifyConfig? config, + required AmplifyAuthProviderRepository authProviderRepo, + }) async { final apiConfig = config?.api?.awsPlugin; if (apiConfig == null) { throw const ApiException('No AWS API config found', diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart index cfd86898f0..bec1774a51 100644 --- a/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart @@ -50,6 +50,7 @@ import 'package:amplify_auth_cognito_dart/src/sdk/cognito_identity_provider.dart VerifyUserAttributeRequest; import 'package:amplify_auth_cognito_dart/src/sdk/sdk_bridge.dart'; import 'package:amplify_auth_cognito_dart/src/state/state.dart'; +import 'package:amplify_auth_cognito_dart/src/util/cognito_iam_auth_provider.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:amplify_secure_storage_dart/amplify_secure_storage_dart.dart'; import 'package:built_collection/built_collection.dart'; @@ -169,10 +170,21 @@ class AmplifyAuthCognitoDart extends AuthPluginInterface } @override - Future<void> configure({AmplifyConfig? config}) async { + Future<void> configure({ + AmplifyConfig? config, + required AmplifyAuthProviderRepository authProviderRepo, + }) async { if (config == null) { throw const AuthException('No Cognito plugin config detected'); } + + // Register auth providers to provide auth functionality to other plugins + // without requiring other plugins to call `Amplify.Auth...` directly. + authProviderRepo.registerAuthProvider( + APIAuthorizationType.iam.authProviderToken, + CognitoIamAuthProvider(), + ); + if (_stateMachine.getOrCreate(AuthStateMachine.type).currentState.type != AuthStateType.notConfigured) { throw const AmplifyAlreadyConfiguredException( diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_iam_auth_provider.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_iam_auth_provider.dart new file mode 100644 index 0000000000..b50be60932 --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_iam_auth_provider.dart @@ -0,0 +1,83 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:async'; + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:aws_signature_v4/aws_signature_v4.dart'; +import 'package:meta/meta.dart'; + +/// [AmplifyAuthProvider] implementation that signs a request using AWS credentials +/// from `Amplify.Auth.fetchAuthSession()` or allows getting credentials directly. +@internal +class CognitoIamAuthProvider extends AWSIamAmplifyAuthProvider { + /// AWS credentials from Auth category. + @override + Future<AWSCredentials> retrieve() async { + final authSession = await Amplify.Auth.fetchAuthSession( + options: const CognitoSessionOptions(getAWSCredentials: true), + ) as CognitoAuthSession; + final credentials = authSession.credentials; + if (credentials == null) { + throw const InvalidCredentialsException( + 'Unable to authorize request with IAM. No AWS credentials.', + ); + } + return credentials; + } + + /// Signs request with AWSSigV4Signer and AWS credentials from `.getCredentials()`. + @override + Future<AWSSignedRequest> authorizeRequest( + AWSBaseHttpRequest request, { + IamAuthProviderOptions? options, + }) async { + if (options == null) { + throw const AuthException( + 'Unable to authorize request with IAM. No region or service provided.', + ); + } + + return _signRequest( + request, + region: options.region, + service: options.service, + credentials: await retrieve(), + ); + } + + /// Takes input [request] as canonical request and generates a signed version. + Future<AWSSignedRequest> _signRequest( + AWSBaseHttpRequest request, { + required String region, + required AWSService service, + required AWSCredentials credentials, + }) { + // Create signer helper params. + final signer = AWSSigV4Signer( + credentialsProvider: AWSCredentialsProvider(credentials), + ); + final scope = AWSCredentialScope( + region: region, + service: service, + ); + + // Finally, create and sign canonical request. + return signer.sign( + request, + credentialScope: scope, + ); + } +} diff --git a/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart b/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart new file mode 100644 index 0000000000..acb126fa66 --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart @@ -0,0 +1,112 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +import 'dart:async'; + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart' + hide InternalErrorException; +import 'package:amplify_auth_cognito_dart/src/util/cognito_iam_auth_provider.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:test/test.dart'; + +import '../common/mock_config.dart'; +import '../common/mock_secure_storage.dart'; + +AWSHttpRequest _generateTestRequest() { + return AWSHttpRequest( + method: AWSHttpMethod.get, + uri: Uri.parse('https://www.amazon.com'), + ); +} + +/// Returns dummy AWS credentials. +class TestAmplifyAuth extends AmplifyAuthCognitoDart { + @override + Future<AuthSession> fetchAuthSession({ + required AuthSessionRequest request, + }) async { + return const CognitoAuthSession( + isSignedIn: true, + credentials: AWSCredentials('fakeKeyId', 'fakeSecret'), + ); + } +} + +void main() { + group( + 'AmplifyAuthCognitoDart plugin registers auth providers during configuration', + () { + late AmplifyAuthCognitoDart plugin; + + setUp(() async { + plugin = AmplifyAuthCognitoDart(credentialStorage: MockSecureStorage()); + }); + + test('registers CognitoIamAuthProvider', () async { + final testAuthRepo = AmplifyAuthProviderRepository(); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + final authProvider = testAuthRepo.getAuthProvider( + APIAuthorizationType.iam.authProviderToken, + ); + expect(authProvider, isA<CognitoIamAuthProvider>()); + }); + }); + + group('CognitoIamAuthProvider', () { + setUpAll(() async { + await Amplify.addPlugin(TestAmplifyAuth()); + }); + + test('gets AWS credentials from Amplify.Auth.fetchAuthSession', () async { + final authProvider = CognitoIamAuthProvider(); + final credentials = await authProvider.retrieve(); + expect(credentials.accessKeyId, isA<String>()); + expect(credentials.secretAccessKey, isA<String>()); + }); + + test('signs a request when calling authorizeRequest', () async { + final authProvider = CognitoIamAuthProvider(); + final authorizedRequest = await authProvider.authorizeRequest( + _generateTestRequest(), + options: const IamAuthProviderOptions( + region: 'us-east-1', + service: AWSService.appSync, + ), + ); + // Note: not intended to be complete test of sigv4 algorithm. + expect(authorizedRequest.headers[AWSHeaders.authorization], isNotEmpty); + const userAgentHeader = + zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; + expect( + authorizedRequest.headers[AWSHeaders.host], + isNotEmpty, + skip: zIsWeb, + ); + expect( + authorizedRequest.headers[userAgentHeader], + contains('aws-sigv4'), + ); + }); + + test('throws when no options provided', () async { + final authProvider = CognitoIamAuthProvider(); + await expectLater( + authProvider.authorizeRequest(_generateTestRequest()), + throwsA(isA<AuthException>()), + ); + }); + }); +} diff --git a/packages/auth/amplify_auth_cognito_dart/test/plugin/delete_user_test.dart b/packages/auth/amplify_auth_cognito_dart/test/plugin/delete_user_test.dart index b589b6b110..15e08de206 100644 --- a/packages/auth/amplify_auth_cognito_dart/test/plugin/delete_user_test.dart +++ b/packages/auth/amplify_auth_cognito_dart/test/plugin/delete_user_test.dart @@ -58,6 +58,8 @@ void main() { late StreamController<AuthHubEvent> hubEventsController; late Stream<AuthHubEvent> hubEvents; + final testAuthRepo = AmplifyAuthProviderRepository(); + final userDeletedEvent = isA<AuthHubEvent>().having( (event) => event.type, 'type', @@ -83,7 +85,10 @@ void main() { group('deleteUser', () { test('throws when signed out', () async { - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); await expectLater(plugin.deleteUser(), throwsSignedOutException); expect(hubEvents, neverEmits(userDeletedEvent)); @@ -96,7 +101,10 @@ void main() { userPoolKeys: userPoolKeys, identityPoolKeys: identityPoolKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient(() async {}); stateMachine.addInstance<CognitoIdentityProviderClient>(mockIdp); @@ -113,7 +121,10 @@ void main() { userPoolKeys: userPoolKeys, identityPoolKeys: identityPoolKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient(() async { throw InternalErrorException(); diff --git a/packages/auth/amplify_auth_cognito_dart/test/plugin/sign_out_test.dart b/packages/auth/amplify_auth_cognito_dart/test/plugin/sign_out_test.dart index 6c9f3fe3a2..fe14fc98be 100644 --- a/packages/auth/amplify_auth_cognito_dart/test/plugin/sign_out_test.dart +++ b/packages/auth/amplify_auth_cognito_dart/test/plugin/sign_out_test.dart @@ -69,6 +69,8 @@ void main() { late StreamController<AuthHubEvent> hubEventsController; late Stream<AuthHubEvent> hubEvents; + final testAuthRepo = AmplifyAuthProviderRepository(); + final emitsSignOutEvent = emitsThrough( isA<AuthHubEvent>().having( (event) => event.type, @@ -112,14 +114,20 @@ void main() { group('signOut', () { test('completes when already signed out', () async { - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); expect(plugin.signOut(), completes); expect(hubEvents, emitsSignOutEvent); }); test('does not clear AWS creds when already signed out', () async { seedStorage(secureStorage, identityPoolKeys: identityPoolKeys); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); await expectLater(plugin.signOut(), completes); expect(hubEvents, emitsSignOutEvent); @@ -144,7 +152,10 @@ void main() { userPoolKeys: userPoolKeys, identityPoolKeys: identityPoolKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient( globalSignOut: () async => GlobalSignOutResponse(), @@ -165,7 +176,10 @@ void main() { userPoolKeys: userPoolKeys, identityPoolKeys: identityPoolKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient( globalSignOut: @@ -194,7 +208,10 @@ void main() { userPoolKeys: userPoolKeys, identityPoolKeys: identityPoolKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient( globalSignOut: () async => GlobalSignOutResponse(), @@ -217,7 +234,10 @@ void main() { test('can sign out in user pool-only mode', () async { seedStorage(secureStorage, userPoolKeys: userPoolKeys); - await plugin.configure(config: userPoolOnlyConfig); + await plugin.configure( + config: userPoolOnlyConfig, + authProviderRepo: testAuthRepo, + ); expect(plugin.signOut(), completes); }); @@ -229,7 +249,10 @@ void main() { identityPoolKeys: identityPoolKeys, hostedUiKeys: hostedUiKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient( globalSignOut: () async => GlobalSignOutResponse(), @@ -250,7 +273,10 @@ void main() { identityPoolKeys: identityPoolKeys, hostedUiKeys: hostedUiKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient( globalSignOut: @@ -279,7 +305,10 @@ void main() { identityPoolKeys: identityPoolKeys, hostedUiKeys: hostedUiKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient( globalSignOut: () async => GlobalSignOutResponse(), @@ -321,7 +350,10 @@ void main() { ), HostedUiPlatform.token, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); await expectLater(plugin.getUserPoolTokens(), completes); await expectLater( From 7a98c5a58a1627075b90c5c669461adad9eca543 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Thu, 21 Jul 2022 12:50:37 -0800 Subject: [PATCH 07/33] feat(core,api): IAM auth mode for HTTP requests (REST and GQL) (#1893) --- .../api/auth/api_authorization_type.dart | 2 +- .../types/common/amplify_auth_provider.dart | 14 ++ .../amplify_authorization_rest_client.dart | 30 ++-- .../amplify_api/lib/src/api_plugin_impl.dart | 64 ++++--- .../decorators/authorize_http_request.dart | 110 ++++++++++++ .../app_sync_api_key_auth_provider.dart | 38 +++++ packages/api/amplify_api/pubspec.yaml | 1 + .../test/amplify_dart_rest_methods_test.dart | 5 +- .../test/authorize_http_request_test.dart | 159 ++++++++++++++++++ .../amplify_api/test/dart_graphql_test.dart | 2 +- .../test/plugin_configuration_test.dart | 112 ++++++++++++ packages/api/amplify_api/test/util.dart | 53 ++++++ 12 files changed, 538 insertions(+), 52 deletions(-) create mode 100644 packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart create mode 100644 packages/api/amplify_api/lib/src/graphql/app_sync_api_key_auth_provider.dart create mode 100644 packages/api/amplify_api/test/authorize_http_request_test.dart create mode 100644 packages/api/amplify_api/test/plugin_configuration_test.dart create mode 100644 packages/api/amplify_api/test/util.dart diff --git a/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart b/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart index f15da13b9f..95b73a4cac 100644 --- a/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart +++ b/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart @@ -35,7 +35,7 @@ enum APIAuthorizationType<T extends AmplifyAuthProvider> { /// See also: /// - [API Key Authorization](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#api-key-authorization) @JsonValue('API_KEY') - apiKey(AmplifyAuthProviderToken<AmplifyAuthProvider>()), + apiKey(AmplifyAuthProviderToken<ApiKeyAmplifyAuthProvider>()), /// Use an IAM access/secret key credential pair to authorize access to an API. /// diff --git a/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart b/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart index 30c00ff053..16707d6afd 100644 --- a/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart +++ b/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart @@ -34,6 +34,12 @@ class IamAuthProviderOptions extends AuthProviderOptions { const IamAuthProviderOptions({required this.region, required this.service}); } +class ApiKeyAuthProviderOptions extends AuthProviderOptions { + final String apiKey; + + const ApiKeyAuthProviderOptions(this.apiKey); +} + abstract class AmplifyAuthProvider { Future<AWSBaseHttpRequest> authorizeRequest( AWSBaseHttpRequest request, { @@ -50,6 +56,14 @@ abstract class AWSIamAmplifyAuthProvider extends AmplifyAuthProvider }); } +abstract class ApiKeyAmplifyAuthProvider extends AmplifyAuthProvider { + @override + Future<AWSBaseHttpRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant ApiKeyAuthProviderOptions? options, + }); +} + abstract class TokenAmplifyAuthProvider extends AmplifyAuthProvider { Future<String> getLatestAuthToken(); diff --git a/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart index 8a2d0678b5..a0b7aece44 100644 --- a/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart +++ b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart @@ -18,15 +18,19 @@ import 'package:amplify_core/amplify_core.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; -const _xApiKey = 'X-Api-Key'; +import 'decorators/authorize_http_request.dart'; /// Implementation of http [http.Client] that authorizes HTTP requests with /// Amplify. @internal class AmplifyAuthorizationRestClient extends http.BaseClient implements Closeable { + /// [AmplifyAuthProviderRepository] for any auth modes this client may use. + final AmplifyAuthProviderRepository authProviderRepo; + /// Determines how requests with this client are authorized. final AWSApiConfig endpointConfig; + final http.Client _baseClient; final bool _useDefaultBaseClient; @@ -34,6 +38,7 @@ class AmplifyAuthorizationRestClient extends http.BaseClient /// client are authorized. AmplifyAuthorizationRestClient({ required this.endpointConfig, + required this.authProviderRepo, http.Client? baseClient, }) : _useDefaultBaseClient = baseClient == null, _baseClient = baseClient ?? http.Client(); @@ -42,27 +47,14 @@ class AmplifyAuthorizationRestClient extends http.BaseClient /// header already set. @override Future<http.StreamedResponse> send(http.BaseRequest request) async => - _baseClient.send(_authorizeRequest(request)); + _baseClient.send(await authorizeHttpRequest( + request, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + )); @override void close() { if (_useDefaultBaseClient) _baseClient.close(); } - - http.BaseRequest _authorizeRequest(http.BaseRequest request) { - if (!request.headers.containsKey(AWSHeaders.authorization) && - endpointConfig.authorizationType != APIAuthorizationType.none) { - // TODO(ragingsquirrel3): Use auth providers from core to transform the request. - final apiKey = endpointConfig.apiKey; - if (endpointConfig.authorizationType == APIAuthorizationType.apiKey) { - if (apiKey == null) { - throw const ApiException( - 'Auth mode is API Key, but no API Key was found in config.'); - } - - request.headers.putIfAbsent(_xApiKey, () => apiKey); - } - } - return request; - } } diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index a5dfd58ce6..e353c70a31 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -26,6 +26,7 @@ import 'package:meta/meta.dart'; import 'amplify_api_config.dart'; import 'amplify_authorization_rest_client.dart'; +import 'graphql/app_sync_api_key_auth_provider.dart'; import 'graphql/send_graphql_request.dart'; import 'util.dart'; @@ -35,10 +36,11 @@ import 'util.dart'; class AmplifyAPIDart extends AmplifyAPI { late final AWSApiPluginConfig _apiConfig; final http.Client? _baseHttpClient; + late final AmplifyAuthProviderRepository _authProviderRepo; /// A map of the keys from the Amplify API config to HTTP clients to use for /// requests to that endpoint. - final Map<String, AmplifyAuthorizationRestClient> _clientPool = {}; + final Map<String, http.Client> _clientPool = {}; /// The registered [APIAuthProvider] instances. final Map<APIAuthorizationType, APIAuthProvider> _authProviders = {}; @@ -65,6 +67,21 @@ class AmplifyAPIDart extends AmplifyAPI { 'https://docs.amplify.aws/lib/graphqlapi/getting-started/q/platform/flutter/#configure-api'); } _apiConfig = apiConfig; + _authProviderRepo = authProviderRepo; + _registerApiPluginAuthProviders(); + } + + /// If an endpoint has an API key, ensure valid auth provider registered. + void _registerApiPluginAuthProviders() { + _apiConfig.endpoints.forEach((key, value) { + // Check the presence of apiKey (not auth type) because other modes might + // have a key if not the primary auth mode. + if (value.apiKey != null) { + _authProviderRepo.registerAuthProvider( + value.authorizationType.authProviderToken, + AppSyncApiKeyAuthProvider()); + } + }); } @override @@ -89,32 +106,21 @@ class AmplifyAPIDart extends AmplifyAPI { } } - /// Returns the HTTP client to be used for GraphQL operations. + /// Returns the HTTP client to be used for REST/GraphQL operations. /// - /// Use [apiName] if there are multiple GraphQL endpoints. + /// Use [apiName] if there are multiple endpoints of the same type. @visibleForTesting - http.Client getGraphQLClient({String? apiName}) { + http.Client getHttpClient(EndpointType type, {String? apiName}) { final endpoint = _apiConfig.getEndpoint( - type: EndpointType.graphQL, + type: type, apiName: apiName, ); - return _clientPool[endpoint.name] ??= AmplifyAuthorizationRestClient( - endpointConfig: endpoint.config, baseClient: _baseHttpClient); - } - - /// Returns the HTTP client to be used for REST operations. - /// - /// Use [apiName] if there are multiple REST endpoints. - @visibleForTesting - http.Client getRestClient({String? apiName}) { - final endpoint = _apiConfig.getEndpoint( - type: EndpointType.rest, - apiName: apiName, - ); - return _clientPool[endpoint.name] ??= AmplifyAuthorizationRestClient( + return _clientPool[endpoint.name] ??= AmplifyHttpClient( + baseClient: AmplifyAuthorizationRestClient( endpointConfig: endpoint.config, baseClient: _baseHttpClient, - ); + authProviderRepo: _authProviderRepo, + )); } Uri _getGraphQLUri(String? apiName) { @@ -160,7 +166,8 @@ class AmplifyAPIDart extends AmplifyAPI { @override CancelableOperation<GraphQLResponse<T>> query<T>( {required GraphQLRequest<T> request}) { - final graphQLClient = getGraphQLClient(apiName: request.apiName); + final graphQLClient = + getHttpClient(EndpointType.graphQL, apiName: request.apiName); final uri = _getGraphQLUri(request.apiName); final responseFuture = sendGraphQLRequest<T>( @@ -171,7 +178,8 @@ class AmplifyAPIDart extends AmplifyAPI { @override CancelableOperation<GraphQLResponse<T>> mutate<T>( {required GraphQLRequest<T> request}) { - final graphQLClient = getGraphQLClient(apiName: request.apiName); + final graphQLClient = + getHttpClient(EndpointType.graphQL, apiName: request.apiName); final uri = _getGraphQLUri(request.apiName); final responseFuture = sendGraphQLRequest<T>( @@ -190,7 +198,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse(AWSStreamedHttpRequest.delete( uri, body: body ?? HttpPayload.empty(), @@ -206,7 +214,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSHttpRequest.get( uri, @@ -223,7 +231,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSHttpRequest.head( uri, @@ -241,7 +249,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSStreamedHttpRequest.patch( uri, @@ -260,7 +268,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSStreamedHttpRequest.post( uri, @@ -279,7 +287,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSStreamedHttpRequest.put( uri, diff --git a/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart b/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart new file mode 100644 index 0000000000..3cab4d7443 --- /dev/null +++ b/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart @@ -0,0 +1,110 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:async'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +/// Transforms an HTTP request according to auth providers that match the endpoint +/// configuration. +@internal +Future<http.BaseRequest> authorizeHttpRequest(http.BaseRequest request, + {required AWSApiConfig endpointConfig, + required AmplifyAuthProviderRepository authProviderRepo}) async { + if (request.headers.containsKey(AWSHeaders.authorization)) { + return request; + } + final authType = endpointConfig.authorizationType; + + switch (authType) { + case APIAuthorizationType.apiKey: + final authProvider = _validateAuthProvider( + authProviderRepo + .getAuthProvider(APIAuthorizationType.apiKey.authProviderToken), + authType); + final apiKey = endpointConfig.apiKey; + if (apiKey == null) { + throw const ApiException( + 'Auth mode is API Key, but no API Key was found in config.'); + } + + final authorizedRequest = await authProvider.authorizeRequest( + _httpToAWSRequest(request), + options: ApiKeyAuthProviderOptions(apiKey)); + return authorizedRequest.httpRequest; + case APIAuthorizationType.iam: + final authProvider = _validateAuthProvider( + authProviderRepo + .getAuthProvider(APIAuthorizationType.iam.authProviderToken), + authType); + final service = endpointConfig.endpointType == EndpointType.graphQL + ? AWSService.appSync + : AWSService.apiGatewayManagementApi; // resolves to "execute-api" + + final authorizedRequest = await authProvider.authorizeRequest( + _httpToAWSRequest(request), + options: IamAuthProviderOptions( + region: endpointConfig.region, + service: service, + ), + ); + return authorizedRequest.httpRequest; + case APIAuthorizationType.function: + case APIAuthorizationType.oidc: + case APIAuthorizationType.userPools: + throw UnimplementedError('${authType.name} not implemented.'); + case APIAuthorizationType.none: + return request; + } +} + +T _validateAuthProvider<T extends AmplifyAuthProvider>( + T? authProvider, APIAuthorizationType authType) { + if (authProvider == null) { + throw ApiException('No auth provider found for auth mode ${authType.name}.', + recoverySuggestion: 'Ensure auth plugin correctly configured.'); + } + return authProvider; +} + +AWSBaseHttpRequest _httpToAWSRequest(http.BaseRequest request) { + final method = AWSHttpMethod.fromString(request.method); + if (request is http.Request) { + return AWSHttpRequest( + method: method, + uri: request.url, + headers: { + AWSHeaders.contentType: 'application/x-amz-json-1.1', + ...request.headers, + }, + body: request.bodyBytes, + ); + } else if (request is http.StreamedRequest) { + return AWSStreamedHttpRequest( + method: method, + uri: request.url, + headers: { + AWSHeaders.contentType: 'application/x-amz-json-1.1', + ...request.headers, + }, + body: request.finalize(), + ); + } else { + throw UnimplementedError( + 'Multipart HTTP requests are not supported.', + ); + } +} diff --git a/packages/api/amplify_api/lib/src/graphql/app_sync_api_key_auth_provider.dart b/packages/api/amplify_api/lib/src/graphql/app_sync_api_key_auth_provider.dart new file mode 100644 index 0000000000..bdafe6dbed --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/app_sync_api_key_auth_provider.dart @@ -0,0 +1,38 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:async'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:meta/meta.dart'; + +/// "X-Api-Key", key used for API key header in API key auth mode. +const xApiKey = 'X-Api-Key'; + +/// [AmplifyAuthProvider] implementation that puts an API key in the header. +@internal +class AppSyncApiKeyAuthProvider extends ApiKeyAmplifyAuthProvider { + @override + Future<AWSBaseHttpRequest> authorizeRequest( + AWSBaseHttpRequest request, { + ApiKeyAuthProviderOptions? options, + }) async { + if (options == null) { + throw const ApiException( + 'Called API key auth provider without passing a valid API key.'); + } + request.headers.putIfAbsent(xApiKey, () => options.apiKey); + return request; + } +} diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index 00041dcf57..c7f4848edb 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -27,6 +27,7 @@ dev_dependencies: path: ../../amplify_lints amplify_test: path: ../../amplify_test + aws_signature_v4: ^0.1.0 build_runner: ^2.0.0 flutter_test: sdk: flutter diff --git a/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart b/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart index d8c5162377..8469354830 100644 --- a/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart +++ b/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart @@ -11,8 +11,6 @@ // 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. -import 'dart:convert'; - import 'package:amplify_api/amplify_api.dart'; import 'package:amplify_api/src/api_plugin_impl.dart'; import 'package:amplify_core/amplify_core.dart'; @@ -28,7 +26,8 @@ const _pathThatShouldFail = 'notHere'; class MockAmplifyAPI extends AmplifyAPIDart { @override - http.Client getRestClient({String? apiName}) => MockClient((request) async { + http.Client getHttpClient(EndpointType type, {String? apiName}) => + MockClient((request) async { if (request.body.isNotEmpty) { expect(request.headers['Content-Type'], 'application/json'); } diff --git a/packages/api/amplify_api/test/authorize_http_request_test.dart b/packages/api/amplify_api/test/authorize_http_request_test.dart new file mode 100644 index 0000000000..2179a07ad8 --- /dev/null +++ b/packages/api/amplify_api/test/authorize_http_request_test.dart @@ -0,0 +1,159 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. + +import 'package:amplify_api/src/decorators/authorize_http_request.dart'; +import 'package:amplify_api/src/graphql/app_sync_api_key_auth_provider.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'util.dart'; + +const _region = 'us-east-1'; +const _gqlEndpoint = + 'https://abc123.appsync-api.$_region.amazonaws.com/graphql'; +const _restEndpoint = 'https://xyz456.execute-api.$_region.amazonaws.com/test'; + +http.Request _generateTestRequest(String url) { + return http.Request('GET', Uri.parse(url)); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final authProviderRepo = AmplifyAuthProviderRepository(); + + setUpAll(() { + authProviderRepo.registerAuthProvider( + APIAuthorizationType.apiKey.authProviderToken, + AppSyncApiKeyAuthProvider()); + authProviderRepo.registerAuthProvider( + APIAuthorizationType.iam.authProviderToken, TestIamAuthProvider()); + }); + + group('authorizeHttpRequest', () { + test('no-op for auth mode NONE', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.none, + endpoint: _restEndpoint, + endpointType: EndpointType.rest, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + expect(authorizedRequest.headers.containsKey(AWSHeaders.authorization), + isFalse); + expect(authorizedRequest, inputRequest); + }); + + test('no-op for request with Authorization header already set', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.userPools, + endpoint: _restEndpoint, + endpointType: EndpointType.rest, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + const testAuthValue = 'foo'; + inputRequest.headers + .putIfAbsent(AWSHeaders.authorization, () => testAuthValue); + + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + expect( + authorizedRequest.headers[AWSHeaders.authorization], testAuthValue); + expect(authorizedRequest, inputRequest); + }); + + test('authorizes request with IAM auth provider', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.iam, + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + validateSignedRequest(authorizedRequest); + }); + + test('authorizes request with API key', () async { + const testApiKey = 'abc-123-fake-key'; + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.apiKey, + apiKey: testApiKey, + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + expect( + authorizedRequest.headers[xApiKey], + testApiKey, + ); + }); + + test('throws when API key not in config', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.apiKey, + // no apiKey value provided + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + expectLater( + authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ), + throwsA(isA<ApiException>())); + }); + + test('authorizes with Cognito User Pools auth mode', () {}, skip: true); + + test('authorizes with OIDC auth mode', () {}, skip: true); + + test('authorizes with lambda auth mode', () {}, skip: true); + + test('throws when no auth provider found', () async { + final emptyAuthRepo = AmplifyAuthProviderRepository(); + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.apiKey, + apiKey: 'abc-123-fake-key', + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + expectLater( + authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: emptyAuthRepo, + ), + throwsA(isA<ApiException>())); + }); + }); +} diff --git a/packages/api/amplify_api/test/dart_graphql_test.dart b/packages/api/amplify_api/test/dart_graphql_test.dart index bedd0092f2..4d9d8ec47f 100644 --- a/packages/api/amplify_api/test/dart_graphql_test.dart +++ b/packages/api/amplify_api/test/dart_graphql_test.dart @@ -91,7 +91,7 @@ class MockAmplifyAPI extends AmplifyAPIDart { }) : super(modelProvider: modelProvider); @override - http.Client getGraphQLClient({String? apiName}) => + http.Client getHttpClient(EndpointType type, {String? apiName}) => MockClient((request) async { if (request.body.contains('getBlog')) { return http.Response(json.encode(_expectedModelQueryResult), 200); diff --git a/packages/api/amplify_api/test/plugin_configuration_test.dart b/packages/api/amplify_api/test/plugin_configuration_test.dart new file mode 100644 index 0000000000..fcf3692114 --- /dev/null +++ b/packages/api/amplify_api/test/plugin_configuration_test.dart @@ -0,0 +1,112 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +import 'dart:convert'; + +import 'package:amplify_api/src/api_plugin_impl.dart'; +import 'package:amplify_api/src/graphql/app_sync_api_key_auth_provider.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'test_data/fake_amplify_configuration.dart'; +import 'util.dart'; + +const _expectedQuerySuccessResponseBody = { + 'data': { + 'listBlogs': { + 'items': [ + { + 'id': 'TEST_ID', + 'name': 'Test App Blog', + 'createdAt': '2022-06-28T17:36:52.460Z' + } + ] + } + } +}; + +/// Asserts user agent and API key present. +final _mockGqlClient = MockClient((request) async { + const userAgentHeader = + zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; + expect(request.headers[userAgentHeader], contains('amplify-flutter')); + expect(request.headers[xApiKey], isA<String>()); + return http.Response(json.encode(_expectedQuerySuccessResponseBody), 200); +}); + +/// Asserts user agent and signed. +final _mockRestClient = MockClient((request) async { + const userAgentHeader = + zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; + expect(request.headers[userAgentHeader], contains('amplify-flutter')); + validateSignedRequest(request); + return http.Response('"Hello from Lambda!"', 200); +}); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final authProviderRepo = AmplifyAuthProviderRepository(); + authProviderRepo.registerAuthProvider( + APIAuthorizationType.iam.authProviderToken, TestIamAuthProvider()); + final config = + AmplifyConfig.fromJson(jsonDecode(amplifyconfig) as Map<String, Object?>); + + group('AmplifyAPI plugin configuration', () { + test( + 'should register an API key auth provider when the configuration has an API key', + () async { + final plugin = AmplifyAPIDart(); + await plugin.configure( + authProviderRepo: authProviderRepo, config: config); + final apiKeyAuthProvider = authProviderRepo + .getAuthProvider(APIAuthorizationType.apiKey.authProviderToken); + expect(apiKeyAuthProvider, isA<AppSyncApiKeyAuthProvider>()); + }); + + test( + 'should configure an HTTP client for GraphQL that authorizes with auth providers and adds user-agent', + () async { + final plugin = AmplifyAPIDart(baseHttpClient: _mockGqlClient); + await plugin.configure( + authProviderRepo: authProviderRepo, config: config); + + String graphQLDocument = '''query TestQuery { + listBlogs { + items { + id + name + createdAt + } + } + }'''; + final request = + GraphQLRequest<String>(document: graphQLDocument, variables: {}); + await plugin.query(request: request).value; + // no assertion here because assertion implemented in mock HTTP client + }); + + test( + 'should configure an HTTP client for REST that authorizes with auth providers and adds user-agent', + () async { + final plugin = AmplifyAPIDart(baseHttpClient: _mockRestClient); + await plugin.configure( + authProviderRepo: authProviderRepo, config: config); + + await plugin.get('/items').value; + // no assertion here because assertion implemented in mock HTTP client + }); + }); +} diff --git a/packages/api/amplify_api/test/util.dart b/packages/api/amplify_api/test/util.dart new file mode 100644 index 0000000000..f3c2ef551e --- /dev/null +++ b/packages/api/amplify_api/test/util.dart @@ -0,0 +1,53 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'package:amplify_core/amplify_core.dart'; +import 'package:aws_signature_v4/aws_signature_v4.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +class TestIamAuthProvider extends AWSIamAmplifyAuthProvider { + @override + Future<AWSCredentials> retrieve() async { + return const AWSCredentials( + 'fake-access-key-123', 'fake-secret-access-key-456'); + } + + @override + Future<AWSSignedRequest> authorizeRequest( + AWSBaseHttpRequest request, { + IamAuthProviderOptions? options, + }) async { + final signer = AWSSigV4Signer( + credentialsProvider: AWSCredentialsProvider(await retrieve()), + ); + final scope = AWSCredentialScope( + region: options!.region, + service: AWSService.appSync, + ); + return signer.sign( + request, + credentialScope: scope, + ); + } +} + +void validateSignedRequest(http.BaseRequest request) { + const userAgentHeader = + zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; + expect( + request.headers[userAgentHeader], + contains('aws-sigv4'), + ); +} From 5f2783b13a5abb34f0cb8fac8b800447b0ea00a2 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Thu, 21 Jul 2022 16:32:26 -0700 Subject: [PATCH 08/33] feat(api): .subscribe() for GraphQL --- .../integration_test/graphql_tests.dart | 234 +++++++++--------- .../amplify_api/lib/src/api_plugin_impl.dart | 27 ++ .../authorize_websocket_message.dart | 68 +++++ .../src/graphql/ws/websocket_connection.dart | 186 ++++++++++++++ .../lib/src/graphql/ws/websocket_message.dart | 202 +++++++++++++++ .../websocket_message_stream_transformer.dart | 54 ++++ packages/api/amplify_api/pubspec.yaml | 2 + 7 files changed, 659 insertions(+), 114 deletions(-) create mode 100644 packages/api/amplify_api/lib/src/decorators/authorize_websocket_message.dart create mode 100644 packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart create mode 100644 packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart create mode 100644 packages/api/amplify_api/lib/src/graphql/ws/websocket_message_stream_transformer.dart diff --git a/packages/api/amplify_api/example/integration_test/graphql_tests.dart b/packages/api/amplify_api/example/integration_test/graphql_tests.dart index f1a9a42362..20404fdea3 100644 --- a/packages/api/amplify_api/example/integration_test/graphql_tests.dart +++ b/packages/api/amplify_api/example/integration_test/graphql_tests.dart @@ -529,136 +529,142 @@ void main() { }); }); - group('subscriptions', () { - // Some local helper methods to help with establishing subscriptions and such. - - // Wait for subscription established for given request. - Future<StreamSubscription<GraphQLResponse<T>>> - _getEstablishedSubscriptionOperation<T>( - GraphQLRequest<T> subscriptionRequest, - void Function(GraphQLResponse<T>) onData) async { - Completer<void> establishedCompleter = Completer(); - final stream = - Amplify.API.subscribe<T>(subscriptionRequest, onEstablished: () { - establishedCompleter.complete(); - }); - final subscription = stream.listen( - onData, - onError: (Object e) => fail('Error in subscription stream: $e'), - ); - - await establishedCompleter.future - .timeout(const Duration(seconds: _subscriptionTimeoutInterval)); - return subscription; - } + group( + 'subscriptions', + () { + // Some local helper methods to help with establishing subscriptions and such. + + // Wait for subscription established for given request. + Future<StreamSubscription<GraphQLResponse<T>>> + _getEstablishedSubscriptionOperation<T>( + GraphQLRequest<T> subscriptionRequest, + void Function(GraphQLResponse<T>) onData) async { + Completer<void> establishedCompleter = Completer(); + final stream = + Amplify.API.subscribe<T>(subscriptionRequest, onEstablished: () { + establishedCompleter.complete(); + }); + final subscription = stream.listen( + onData, + onError: (Object e) => fail('Error in subscription stream: $e'), + ); + + await establishedCompleter.future + .timeout(const Duration(seconds: _subscriptionTimeoutInterval)); + return subscription; + } - // Establish subscription for request, do the mutationFunction, then wait - // for the stream event, cancel the operation, return response from event. - Future<GraphQLResponse<T?>> _establishSubscriptionAndMutate<T>( - GraphQLRequest<T> subscriptionRequest, - Future<void> Function() mutationFunction) async { - Completer<GraphQLResponse<T?>> dataCompleter = Completer(); - // With stream established, exec callback with stream events. - final subscription = await _getEstablishedSubscriptionOperation<T>( - subscriptionRequest, (event) { - if (event.hasErrors) { - fail('subscription errors: ${event.errors}'); - } - dataCompleter.complete(event); - }); - await mutationFunction(); - final response = await dataCompleter.future - .timeout((const Duration(seconds: _subscriptionTimeoutInterval))); + // Establish subscription for request, do the mutationFunction, then wait + // for the stream event, cancel the operation, return response from event. + Future<GraphQLResponse<T?>> _establishSubscriptionAndMutate<T>( + GraphQLRequest<T> subscriptionRequest, + Future<void> Function() mutationFunction) async { + Completer<GraphQLResponse<T?>> dataCompleter = Completer(); + // With stream established, exec callback with stream events. + final subscription = await _getEstablishedSubscriptionOperation<T>( + subscriptionRequest, (event) { + if (event.hasErrors) { + fail('subscription errors: ${event.errors}'); + } + dataCompleter.complete(event); + }); + await mutationFunction(); + final response = await dataCompleter.future + .timeout((const Duration(seconds: _subscriptionTimeoutInterval))); + + await subscription.cancel(); + return response; + } - await subscription.cancel(); - return response; - } + testWidgets( + 'should emit event when onCreate subscription made with model helper', + (WidgetTester tester) async { + String name = + 'Integration Test Blog - subscription create ${UUID.getUUID()}'; + final subscriptionRequest = + ModelSubscriptions.onCreate(Blog.classType); - testWidgets( - 'should emit event when onCreate subscription made with model helper', - (WidgetTester tester) async { - String name = - 'Integration Test Blog - subscription create ${UUID.getUUID()}'; - final subscriptionRequest = ModelSubscriptions.onCreate(Blog.classType); + final eventResponse = await _establishSubscriptionAndMutate( + subscriptionRequest, () => addBlog(name)); + Blog? blogFromEvent = eventResponse.data; - final eventResponse = await _establishSubscriptionAndMutate( - subscriptionRequest, () => addBlog(name)); - Blog? blogFromEvent = eventResponse.data; + expect(blogFromEvent?.name, equals(name)); + }); - expect(blogFromEvent?.name, equals(name)); - }); + testWidgets( + 'should emit event when onUpdate subscription made with model helper', + (WidgetTester tester) async { + const originalName = 'Integration Test Blog - subscription update'; + final updatedName = + 'Integration Test Blog - subscription update, name now ${UUID.getUUID()}'; + Blog blogToUpdate = await addBlog(originalName); + + final subscriptionRequest = + ModelSubscriptions.onUpdate(Blog.classType); + final eventResponse = + await _establishSubscriptionAndMutate(subscriptionRequest, () { + blogToUpdate = blogToUpdate.copyWith(name: updatedName); + final updateReq = ModelMutations.update(blogToUpdate); + return Amplify.API.mutate(request: updateReq).response; + }); + Blog? blogFromEvent = eventResponse.data; + + expect(blogFromEvent?.name, equals(updatedName)); + }); - testWidgets( - 'should emit event when onUpdate subscription made with model helper', - (WidgetTester tester) async { - const originalName = 'Integration Test Blog - subscription update'; - final updatedName = - 'Integration Test Blog - subscription update, name now ${UUID.getUUID()}'; - Blog blogToUpdate = await addBlog(originalName); - - final subscriptionRequest = ModelSubscriptions.onUpdate(Blog.classType); - final eventResponse = - await _establishSubscriptionAndMutate(subscriptionRequest, () { - blogToUpdate = blogToUpdate.copyWith(name: updatedName); - final updateReq = ModelMutations.update(blogToUpdate); - return Amplify.API.mutate(request: updateReq).response; + testWidgets( + 'should emit event when onDelete subscription made with model helper', + (WidgetTester tester) async { + const name = 'Integration Test Blog - subscription delete'; + Blog blogToDelete = await addBlog(name); + + final subscriptionRequest = + ModelSubscriptions.onDelete(Blog.classType); + final eventResponse = + await _establishSubscriptionAndMutate(subscriptionRequest, () { + final deleteReq = + ModelMutations.deleteById(Blog.classType, blogToDelete.id); + return Amplify.API.mutate(request: deleteReq).response; + }); + Blog? blogFromEvent = eventResponse.data; + + expect(blogFromEvent?.name, equals(name)); }); - Blog? blogFromEvent = eventResponse.data; - expect(blogFromEvent?.name, equals(updatedName)); - }); + testWidgets('should cancel subscription', (WidgetTester tester) async { + const name = 'Integration Test Blog - subscription to cancel'; + Blog blogToDelete = await addBlog(name); - testWidgets( - 'should emit event when onDelete subscription made with model helper', - (WidgetTester tester) async { - const name = 'Integration Test Blog - subscription delete'; - Blog blogToDelete = await addBlog(name); + final subReq = ModelSubscriptions.onDelete(Blog.classType); + final subscription = + await _getEstablishedSubscriptionOperation<Blog>(subReq, (_) { + fail('Subscription event triggered. Should be canceled.'); + }); + await subscription.cancel(); - final subscriptionRequest = ModelSubscriptions.onDelete(Blog.classType); - final eventResponse = - await _establishSubscriptionAndMutate(subscriptionRequest, () { + // delete the blog, wait for update final deleteReq = ModelMutations.deleteById(Blog.classType, blogToDelete.id); - return Amplify.API.mutate(request: deleteReq).response; + await Amplify.API.mutate(request: deleteReq).response; + await Future<dynamic>.delayed(const Duration(seconds: 5)); }); - Blog? blogFromEvent = eventResponse.data; - expect(blogFromEvent?.name, equals(name)); - }); + testWidgets( + 'should emit event when onCreate subscription made with model helper for post (model with parent).', + (WidgetTester tester) async { + String title = + 'Integration Test post - subscription create ${UUID.getUUID()}'; + final subscriptionRequest = + ModelSubscriptions.onCreate(Post.classType); - testWidgets('should cancel subscription', (WidgetTester tester) async { - const name = 'Integration Test Blog - subscription to cancel'; - Blog blogToDelete = await addBlog(name); + final eventResponse = await _establishSubscriptionAndMutate( + subscriptionRequest, + () => addPostAndBlogWithModelHelper(title, 0)); + Post? postFromEvent = eventResponse.data; - final subReq = ModelSubscriptions.onDelete(Blog.classType); - final subscription = - await _getEstablishedSubscriptionOperation<Blog>(subReq, (_) { - fail('Subscription event triggered. Should be canceled.'); + expect(postFromEvent?.title, equals(title)); }); - await subscription.cancel(); - - // delete the blog, wait for update - final deleteReq = - ModelMutations.deleteById(Blog.classType, blogToDelete.id); - await Amplify.API.mutate(request: deleteReq).response; - await Future<dynamic>.delayed(const Duration(seconds: 5)); - }); - - testWidgets( - 'should emit event when onCreate subscription made with model helper for post (model with parent).', - (WidgetTester tester) async { - String title = - 'Integration Test post - subscription create ${UUID.getUUID()}'; - final subscriptionRequest = ModelSubscriptions.onCreate(Post.classType); - - final eventResponse = await _establishSubscriptionAndMutate( - subscriptionRequest, () => addPostAndBlogWithModelHelper(title, 0)); - Post? postFromEvent = eventResponse.data; - - expect(postFromEvent?.title, equals(title)); - }); - }, - skip: - 'TODO(ragingsquirrel3): re-enable tests once subscriptions are implemented.'); + }, + ); }); } diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index e353c70a31..e7847a7303 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -17,6 +17,7 @@ library amplify_api; import 'dart:io'; import 'package:amplify_api/amplify_api.dart'; +import 'package:amplify_api/src/graphql/ws/websocket_connection.dart'; import 'package:amplify_api/src/native_api_plugin.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:async/async.dart'; @@ -42,6 +43,10 @@ class AmplifyAPIDart extends AmplifyAPI { /// requests to that endpoint. final Map<String, http.Client> _clientPool = {}; + /// A map of the keys from the Amplify API config websocket connections to use + /// for that endpoint. + final Map<String, WebSocketConnection> _webSocketConnectionPool = {}; + /// The registered [APIAuthProvider] instances. final Map<APIAuthorizationType, APIAuthProvider> _authProviders = {}; @@ -123,6 +128,19 @@ class AmplifyAPIDart extends AmplifyAPI { )); } + /// Returns the websocket connection to use for a given endpoint. + /// + /// Use [apiName] if there are multiple endpoints. + @visibleForTesting + WebSocketConnection getWebsocketConnection({String? apiName}) { + final endpoint = _apiConfig.getEndpoint( + type: EndpointType.graphQL, + apiName: apiName, + ); + return _webSocketConnectionPool[endpoint.name] ??= + WebSocketConnection(endpoint.config, _authProviderRepo); + } + Uri _getGraphQLUri(String? apiName) { final endpoint = _apiConfig.getEndpoint( type: EndpointType.graphQL, @@ -187,6 +205,15 @@ class AmplifyAPIDart extends AmplifyAPI { return _makeCancelable<GraphQLResponse<T>>(responseFuture); } + @override + Stream<GraphQLResponse<T>> subscribe<T>( + GraphQLRequest<T> request, { + void Function()? onEstablished, + }) { + return getWebsocketConnection(apiName: request.apiName) + .subscribe(request, onEstablished); + } + // ====== REST ======= @override diff --git a/packages/api/amplify_api/lib/src/decorators/authorize_websocket_message.dart b/packages/api/amplify_api/lib/src/decorators/authorize_websocket_message.dart new file mode 100644 index 0000000000..e72426201f --- /dev/null +++ b/packages/api/amplify_api/lib/src/decorators/authorize_websocket_message.dart @@ -0,0 +1,68 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'package:amplify_core/amplify_core.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +import '../graphql/ws/websocket_message.dart'; +import 'authorize_http_request.dart'; + +/// Takes input websocket message (connection or subscription establisher) and +/// adds authorization headers from auth repo. +@internal +Future<WebSocketMessage> authorizeWebSocketMessage( + WebSocketMessage inputMessage, + AWSApiConfig config, + AmplifyAuthProviderRepository authRepo, +) async { + final body = inputMessage.payload?.toJson()['data']; + if (inputMessage is WebSocketConnectionInitMessage) { + inputMessage.authorizationHeaders = + await _generateAuthorizationHeaders(config, authRepo: authRepo); + } else if (body is String) { + inputMessage.payload?.authorizationHeaders = + await _generateAuthorizationHeaders(config, + authRepo: authRepo, body: body); + } + return Future.value(inputMessage); +} + +Future<Map<String, dynamic>> _generateAuthorizationHeaders( + AWSApiConfig config, { + required AmplifyAuthProviderRepository authRepo, + String body = '{}', +}) async { + final endpointHost = Uri.parse(config.endpoint).host; + // Create canonical HTTP request to authorize. + final maybeConnect = body != '{}' ? '' : '/connect'; + final canonicalHttpRequest = + http.Request('POST', Uri.parse('${config.endpoint}$maybeConnect')); + canonicalHttpRequest.headers.addAll({ + AWSHeaders.accept: 'application/json, text/javascript', + AWSHeaders.contentEncoding: 'amz-1.0', + AWSHeaders.contentType: 'application/json; charset=UTF-8', + }); + canonicalHttpRequest.body = body; + + final authorizedHttpRequest = await authorizeHttpRequest( + canonicalHttpRequest, + endpointConfig: config, + authProviderRepo: authRepo, + ); + return { + ...authorizedHttpRequest.headers, + AWSHeaders.host: endpointHost, + }; +} diff --git a/packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart b/packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart new file mode 100644 index 0000000000..30df9d1b9f --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart @@ -0,0 +1,186 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:amplify_api/src/decorators/authorize_websocket_message.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:async/async.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import 'websocket_message.dart'; +import 'websocket_message_stream_transformer.dart'; + +/// {@template websocket_connection} +/// Manages connection with an AppSync backend and subscription routing. +/// {@endtemplate} +class WebSocketConnection implements Closeable { + static const webSocketProtocols = ['graphql-ws']; + + final AmplifyAuthProviderRepository authProviderRepo; + + final AWSApiConfig _config; + late final WebSocketChannel _channel; + late final StreamSubscription<WebSocketMessage> _subscription; + late final RestartableTimer _timeoutTimer; + + // Add connection error variable to throw in `init`. + + Future<void>? _initFuture; + final Completer<void> _connectionReady = Completer<void>(); + + /// Fires when the connection is ready to be listened to, i.e. + /// after the first `connection_ack` message. + Future<void> get ready => _connectionReady.future; + + /// Re-broadcasts messages for child streams. + final StreamController<WebSocketMessage> _rebroadcastController = + StreamController<WebSocketMessage>.broadcast(); + + /// Incoming message stream for all events. + Stream<WebSocketMessage> get _messageStream => _rebroadcastController.stream; + + /// {@macro websocket_connection} + WebSocketConnection(this._config, this.authProviderRepo); + + /// Connects to the real time WebSocket. + Future<void> _connect() async { + // Generate a URI for the connection and all subscriptions. + // See https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html#handshake-details-to-establish-the-websocket-connection + final connectionMessage = WebSocketConnectionInitMessage(_config); + final authorizedConnectionMessage = await authorizeWebSocketMessage( + connectionMessage, _config, authProviderRepo) + as WebSocketConnectionInitMessage; + final connectionUri = authorizedConnectionMessage.getConnectionUri(); + + _channel = WebSocketChannel.connect( + connectionUri, + protocols: webSocketProtocols, + ); + _subscription = _channel.stream + .transform(const WebSocketMessageStreamTransformer()) + .listen(_onData); + } + + /// Closes the WebSocket connection. + @override + void close() { + _subscription.cancel(); + _channel.sink.close(); + } + + /// Initializes the connection. + Future<void> init() { + return _initFuture ??= _init(); + } + + Future<void> _init() async { + await _connect(); + if (_connectionReady.isCompleted) return; + send(MessageType.connectionInit); + return ready; + } + + /// Subscribes to the given GraphQL request. Returns the subscription object, + /// or throws an [Exception] if there's an error. + Stream<GraphQLResponse<T>> subscribe<T>( + GraphQLRequest<T> request, + void Function()? onEstablished, + ) { + if (!_connectionReady.isCompleted) { + init(); + } + final subRegistration = WebSocketMessage( + messageType: MessageType.start, + payload: + SubscriptionRegistrationPayload(request: request, config: _config), + ); + final subscriptionId = subRegistration.id!; + return _messageStream + .where((msg) => msg.id == subscriptionId) + .transform( + WebSocketSubscriptionStreamTransformer(request, onEstablished)) + .asBroadcastStream( + onListen: (_) => _send(subRegistration), + onCancel: (_) => _cancel(subscriptionId), + ); + } + + /// Cancels a subscription. + void _cancel(String subscriptionId) { + _send(WebSocketMessage( + id: subscriptionId, + messageType: MessageType.stop, + )); + // TODO(equartey): if this is the only susbscription, close the connection. + } + + /// Sends a structured message over the WebSocket. + void send(MessageType type, {WebSocketMessagePayload? payload}) { + final message = WebSocketMessage(messageType: type, payload: payload); + _send(message); + } + + /// Sends a structured message over the WebSocket. + Future<void> _send(WebSocketMessage message) async { + final authorizedMessage = + await authorizeWebSocketMessage(message, _config, authProviderRepo); + final authorizedJson = authorizedMessage.toJson(); + final msgJson = json.encode(authorizedJson); + // print('Sent: $msgJson'); + _channel.sink.add(msgJson); + } + + /// Times out the connection (usually if a keep alive has not been received in time). + void _timeout(Duration timeoutDuration) { + _rebroadcastController.addError( + TimeoutException( + 'Connection timeout', + timeoutDuration, + ), + ); + } + + /// Handles incoming data on the WebSocket. + void _onData(WebSocketMessage message) { + // print('Received: message $message'); + switch (message.messageType) { + case MessageType.connectionAck: + final messageAck = message.payload as ConnectionAckMessagePayload; + final timeoutDuration = Duration( + milliseconds: messageAck.connectionTimeoutMs, + ); + _timeoutTimer = RestartableTimer( + timeoutDuration, + () => _timeout(timeoutDuration), + ); + _connectionReady.complete(); + // print('Registered timer'); + return; + case MessageType.connectionError: + final wsError = message.payload as WebSocketError?; + _connectionReady.completeError( + wsError ?? + Exception( + 'An unknown error occurred while connecting to the WebSocket', + ), + ); + return; + case MessageType.keepAlive: + _timeoutTimer.reset(); + // print('Reset timer'); + return; + case MessageType.error: + // Only handle general messages, not subscription-specific ones + if (message.id != null) { + break; + } + final wsError = message.payload as WebSocketError; + _rebroadcastController.addError(wsError); + return; + default: + break; + } + + // Re-broadcast unhandled messages + _rebroadcastController.add(message); + } +} diff --git a/packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart b/packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart new file mode 100644 index 0000000000..97609e8de3 --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart @@ -0,0 +1,202 @@ +import 'dart:convert'; + +import 'package:amplify_core/amplify_core.dart'; + +class MessageType { + final String type; + + const MessageType._(this.type); + + factory MessageType.fromJson(dynamic json) => + values.firstWhere((el) => json == el.type); + + static const List<MessageType> values = [ + connectionInit, + connectionAck, + connectionError, + start, + startAck, + error, + data, + stop, + keepAlive, + complete, + ]; + + static const connectionInit = MessageType._('connection_init'); + static const connectionAck = MessageType._('connection_ack'); + static const connectionError = MessageType._('connection_error'); + static const error = MessageType._('error'); + static const start = MessageType._('start'); + static const startAck = MessageType._('start_ack'); + static const data = MessageType._('data'); + static const stop = MessageType._('stop'); + static const keepAlive = MessageType._('ka'); + static const complete = MessageType._('complete'); + + @override + String toString() => type; +} + +abstract class WebSocketMessagePayload { + Map<String, dynamic> authorizationHeaders = {}; + + WebSocketMessagePayload(); + + static const Map<MessageType, WebSocketMessagePayload Function(Map)> + _factories = { + MessageType.connectionAck: ConnectionAckMessagePayload.fromJson, + MessageType.data: SubscriptionDataPayload.fromJson, + MessageType.error: WebSocketError.fromJson, + }; + + static WebSocketMessagePayload? fromJson(Map json, MessageType type) { + return _factories[type]?.call(json); + } + + Map<String, dynamic> toJson(); + + @override + String toString() => prettyPrintJson(toJson()); +} + +class ConnectionAckMessagePayload extends WebSocketMessagePayload { + final int connectionTimeoutMs; + + ConnectionAckMessagePayload(this.connectionTimeoutMs); + + static ConnectionAckMessagePayload fromJson(Map json) { + final connectionTimeoutMs = json['connectionTimeoutMs'] as int; + return ConnectionAckMessagePayload(connectionTimeoutMs); + } + + @override + Map<String, dynamic> toJson() => <String, dynamic>{ + 'connectionTimeoutMs': connectionTimeoutMs, + }; +} + +class SubscriptionRegistrationPayload extends WebSocketMessagePayload { + final GraphQLRequest request; + final AWSApiConfig config; + + SubscriptionRegistrationPayload({ + required this.request, + required this.config, + }); + + @override + Map<String, dynamic> toJson() { + return <String, dynamic>{ + 'data': jsonEncode( + {'variables': request.variables, 'query': request.document}), + 'extensions': <String, dynamic>{'authorization': authorizationHeaders} + }; + } +} + +class SubscriptionDataPayload extends WebSocketMessagePayload { + final Map<String, dynamic>? data; + final Map<String, dynamic>? errors; + + SubscriptionDataPayload(this.data, this.errors); + + static SubscriptionDataPayload fromJson(Map json) { + final data = json['data'] as Map?; + final errors = json['errors'] as Map?; + return SubscriptionDataPayload( + data?.cast<String, dynamic>(), + errors?.cast<String, dynamic>(), + ); + } + + @override + Map<String, dynamic> toJson() => <String, dynamic>{ + 'data': data, + 'errors': errors, + }; +} + +class WebSocketError extends WebSocketMessagePayload implements Exception { + final List<Map> errors; + + WebSocketError(this.errors); + + static WebSocketError fromJson(Map json) { + final errors = json['errors'] as List?; + return WebSocketError(errors?.cast() ?? []); + } + + @override + Map<String, dynamic> toJson() => <String, dynamic>{ + 'errors': errors, + }; +} + +class WebSocketMessage { + final String? id; + final MessageType messageType; + final WebSocketMessagePayload? payload; + + WebSocketMessage({ + String? id, + required this.messageType, + this.payload, + }) : id = id ?? UUID.getUUID(); + + WebSocketMessage._({ + this.id, + required this.messageType, + this.payload, + }); + + static WebSocketMessage fromJson(Map json) { + final id = json['id'] as String?; + final type = json['type'] as String; + final messageType = MessageType.fromJson(type); + final payloadMap = json['payload'] as Map?; + final payload = payloadMap == null + ? null + : WebSocketMessagePayload.fromJson( + payloadMap, + messageType, + ); + return WebSocketMessage._( + id: id, + messageType: messageType, + payload: payload, + ); + } + + Map<String, dynamic> toJson() => <String, dynamic>{ + if (id != null) 'id': id, + 'type': messageType.type, + if (payload != null) 'payload': payload?.toJson(), + }; + + @override + String toString() { + return prettyPrintJson(this); + } +} + +class WebSocketConnectionInitMessage extends WebSocketMessage { + final AWSApiConfig config; + Map<String, dynamic> authorizationHeaders = {}; + + WebSocketConnectionInitMessage(this.config) + : super(messageType: MessageType.connectionInit); + + Uri getConnectionUri() { + final encodedAuthHeaders = + base64.encode(json.encode(authorizationHeaders).codeUnits); + final endpointUri = Uri.parse( + config.endpoint.replaceFirst('appsync-api', 'appsync-realtime-api')); + return Uri(scheme: 'wss', host: endpointUri.host, path: 'graphql') + .replace(queryParameters: <String, String>{ + 'header': encodedAuthHeaders, + 'payload': + base64.encode(utf8.encode(json.encode({}))) // always payload of '{}' + }); + } +} diff --git a/packages/api/amplify_api/lib/src/graphql/ws/websocket_message_stream_transformer.dart b/packages/api/amplify_api/lib/src/graphql/ws/websocket_message_stream_transformer.dart new file mode 100644 index 0000000000..90afd0d429 --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/ws/websocket_message_stream_transformer.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:amplify_api/src/util.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'websocket_message.dart'; + +import '../graphql_response_decoder.dart'; + +class WebSocketMessageStreamTransformer + extends StreamTransformerBase<dynamic, WebSocketMessage> { + const WebSocketMessageStreamTransformer(); + + @override + Stream<WebSocketMessage> bind(Stream<dynamic> stream) { + return stream.cast<String>().map<Map>((str) { + return json.decode(str) as Map; + }).map(WebSocketMessage.fromJson); + } +} + +class WebSocketSubscriptionStreamTransformer<T> + extends StreamTransformerBase<WebSocketMessage, GraphQLResponse<T>> { + final GraphQLRequest<T> request; + final void Function()? onEstablished; + + const WebSocketSubscriptionStreamTransformer( + this.request, this.onEstablished); + + @override + Stream<GraphQLResponse<T>> bind(Stream<WebSocketMessage> stream) async* { + await for (var event in stream) { + switch (event.messageType) { + case MessageType.startAck: + onEstablished?.call(); + break; + case MessageType.data: + final payload = event.payload as SubscriptionDataPayload; + final errors = deserializeGraphQLResponseErrors(payload.toJson()); + yield GraphQLResponseDecoder.instance.decode<T>( + request: request, + data: json.encode(payload.data), + errors: errors); + + break; + case MessageType.error: + final error = event.payload as WebSocketError; + throw error; + case MessageType.complete: + return; + } + } + } +} diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index c7f4848edb..0c05cfb643 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -21,6 +21,8 @@ dependencies: http: ^0.13.4 meta: ^1.7.0 plugin_platform_interface: ^2.0.0 + web_socket_channel: ^2.2.0 + dev_dependencies: amplify_lints: From 55931aa1a3ec603598d94bc36f80c9ff40baba8d Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Thu, 21 Jul 2022 16:42:22 -0700 Subject: [PATCH 09/33] change name --- packages/api/amplify_api/lib/src/api_plugin_impl.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index e7847a7303..7a60cdda0e 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -132,7 +132,7 @@ class AmplifyAPIDart extends AmplifyAPI { /// /// Use [apiName] if there are multiple endpoints. @visibleForTesting - WebSocketConnection getWebsocketConnection({String? apiName}) { + WebSocketConnection getWebSocketConnection({String? apiName}) { final endpoint = _apiConfig.getEndpoint( type: EndpointType.graphQL, apiName: apiName, @@ -210,7 +210,7 @@ class AmplifyAPIDart extends AmplifyAPI { GraphQLRequest<T> request, { void Function()? onEstablished, }) { - return getWebsocketConnection(apiName: request.apiName) + return getWebSocketConnection(apiName: request.apiName) .subscribe(request, onEstablished); } From b6ebdabe8884d5eebc2cd85dbd46f82ccef95e02 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Tue, 26 Jul 2022 15:34:40 -0700 Subject: [PATCH 10/33] add some unit tests --- .../authorize_websocket_message.dart | 21 +-- .../src/graphql/ws/websocket_connection.dart | 76 ++++---- .../lib/src/graphql/ws/websocket_message.dart | 27 +-- .../test/ws/web_socket_connection_test.dart | 171 ++++++++++++++++++ 4 files changed, 233 insertions(+), 62 deletions(-) create mode 100644 packages/api/amplify_api/test/ws/web_socket_connection_test.dart diff --git a/packages/api/amplify_api/lib/src/decorators/authorize_websocket_message.dart b/packages/api/amplify_api/lib/src/decorators/authorize_websocket_message.dart index e72426201f..f4b1d699d1 100644 --- a/packages/api/amplify_api/lib/src/decorators/authorize_websocket_message.dart +++ b/packages/api/amplify_api/lib/src/decorators/authorize_websocket_message.dart @@ -22,24 +22,21 @@ import 'authorize_http_request.dart'; /// Takes input websocket message (connection or subscription establisher) and /// adds authorization headers from auth repo. @internal -Future<WebSocketMessage> authorizeWebSocketMessage( - WebSocketMessage inputMessage, +Future<WebSocketSubscriptionRegistrationMessage> authorizeWebSocketMessage( + WebSocketSubscriptionRegistrationMessage inputMessage, AWSApiConfig config, AmplifyAuthProviderRepository authRepo, ) async { final body = inputMessage.payload?.toJson()['data']; - if (inputMessage is WebSocketConnectionInitMessage) { - inputMessage.authorizationHeaders = - await _generateAuthorizationHeaders(config, authRepo: authRepo); - } else if (body is String) { - inputMessage.payload?.authorizationHeaders = - await _generateAuthorizationHeaders(config, - authRepo: authRepo, body: body); - } - return Future.value(inputMessage); + + final payload = inputMessage.payload as SubscriptionRegistrationPayload?; + payload?.authorizationHeaders = await generateAuthorizationHeaders(config, + authRepo: authRepo, body: body as String); + return inputMessage; } -Future<Map<String, dynamic>> _generateAuthorizationHeaders( +@internal +Future<Map<String, String>> generateAuthorizationHeaders( AWSApiConfig config, { required AmplifyAuthProviderRepository authRepo, String body = '{}', diff --git a/packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart b/packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart index 30df9d1b9f..9eeebe95ee 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:amplify_api/src/decorators/authorize_websocket_message.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:async/async.dart'; +import 'package:flutter/material.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'websocket_message.dart'; @@ -41,23 +42,22 @@ class WebSocketConnection implements Closeable { /// {@macro websocket_connection} WebSocketConnection(this._config, this.authProviderRepo); - /// Connects to the real time WebSocket. - Future<void> _connect() async { - // Generate a URI for the connection and all subscriptions. - // See https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html#handshake-details-to-establish-the-websocket-connection - final connectionMessage = WebSocketConnectionInitMessage(_config); - final authorizedConnectionMessage = await authorizeWebSocketMessage( - connectionMessage, _config, authProviderRepo) - as WebSocketConnectionInitMessage; - final connectionUri = authorizedConnectionMessage.getConnectionUri(); + @visibleForTesting + StreamSubscription<WebSocketMessage> getStreamSubscription( + Stream<dynamic> stream) { + return stream + .transform(const WebSocketMessageStreamTransformer()) + .listen(_onData); + } + /// Connects to the real time WebSocket. + @visibleForTesting + Future<void> connect(Uri connectionUri) async { _channel = WebSocketChannel.connect( connectionUri, protocols: webSocketProtocols, ); - _subscription = _channel.stream - .transform(const WebSocketMessageStreamTransformer()) - .listen(_onData); + _subscription = getStreamSubscription(_channel.stream); } /// Closes the WebSocket connection. @@ -73,9 +73,13 @@ class WebSocketConnection implements Closeable { } Future<void> _init() async { - await _connect(); + // Generate a URI for the connection and all subscriptions. + // See https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html#handshake-details-to-establish-the-websocket-connection + final connectionUri = await _getConnectionUri(_config, authProviderRepo); + await connect(connectionUri); + if (_connectionReady.isCompleted) return; - send(MessageType.connectionInit); + send(WebSocketConnectionInitMessage()); return ready; } @@ -88,8 +92,7 @@ class WebSocketConnection implements Closeable { if (!_connectionReady.isCompleted) { init(); } - final subRegistration = WebSocketMessage( - messageType: MessageType.start, + final subRegistration = WebSocketSubscriptionRegistrationMessage( payload: SubscriptionRegistrationPayload(request: request, config: _config), ); @@ -99,14 +102,14 @@ class WebSocketConnection implements Closeable { .transform( WebSocketSubscriptionStreamTransformer(request, onEstablished)) .asBroadcastStream( - onListen: (_) => _send(subRegistration), + onListen: (_) => send(subRegistration), onCancel: (_) => _cancel(subscriptionId), ); } /// Cancels a subscription. void _cancel(String subscriptionId) { - _send(WebSocketMessage( + send(WebSocketMessage( id: subscriptionId, messageType: MessageType.stop, )); @@ -114,18 +117,14 @@ class WebSocketConnection implements Closeable { } /// Sends a structured message over the WebSocket. - void send(MessageType type, {WebSocketMessagePayload? payload}) { - final message = WebSocketMessage(messageType: type, payload: payload); - _send(message); - } - - /// Sends a structured message over the WebSocket. - Future<void> _send(WebSocketMessage message) async { - final authorizedMessage = - await authorizeWebSocketMessage(message, _config, authProviderRepo); - final authorizedJson = authorizedMessage.toJson(); - final msgJson = json.encode(authorizedJson); - // print('Sent: $msgJson'); + @visibleForTesting + Future<void> send(WebSocketMessage message) async { + var authorizedMessage = message; + if (message is WebSocketSubscriptionRegistrationMessage) { + authorizedMessage = + await authorizeWebSocketMessage(message, _config, authProviderRepo); + } + final msgJson = json.encode(authorizedMessage.toJson()); _channel.sink.add(msgJson); } @@ -141,7 +140,6 @@ class WebSocketConnection implements Closeable { /// Handles incoming data on the WebSocket. void _onData(WebSocketMessage message) { - // print('Received: message $message'); switch (message.messageType) { case MessageType.connectionAck: final messageAck = message.payload as ConnectionAckMessagePayload; @@ -184,3 +182,19 @@ class WebSocketConnection implements Closeable { _rebroadcastController.add(message); } } + +Future<Uri> _getConnectionUri( + AWSApiConfig config, AmplifyAuthProviderRepository authRepo) async { + const body = '{}'; + final authorizationHeaders = await generateAuthorizationHeaders(config, + authRepo: authRepo, body: body); + final encodedAuthHeaders = + base64.encode(json.encode(authorizationHeaders).codeUnits); + final endpointUri = Uri.parse( + config.endpoint.replaceFirst('appsync-api', 'appsync-realtime-api')); + return Uri(scheme: 'wss', host: endpointUri.host, path: 'graphql') + .replace(queryParameters: <String, String>{ + 'header': encodedAuthHeaders, + 'payload': base64.encode(utf8.encode(body)) // always payload of '{}' + }); +} diff --git a/packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart b/packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart index 97609e8de3..3729f7cb6e 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart @@ -39,8 +39,6 @@ class MessageType { } abstract class WebSocketMessagePayload { - Map<String, dynamic> authorizationHeaders = {}; - WebSocketMessagePayload(); static const Map<MessageType, WebSocketMessagePayload Function(Map)> @@ -79,6 +77,7 @@ class ConnectionAckMessagePayload extends WebSocketMessagePayload { class SubscriptionRegistrationPayload extends WebSocketMessagePayload { final GraphQLRequest request; final AWSApiConfig config; + Map<String, dynamic> authorizationHeaders = {}; SubscriptionRegistrationPayload({ required this.request, @@ -142,7 +141,7 @@ class WebSocketMessage { String? id, required this.messageType, this.payload, - }) : id = id ?? UUID.getUUID(); + }) : id = id ?? uuid(); WebSocketMessage._({ this.id, @@ -181,22 +180,12 @@ class WebSocketMessage { } class WebSocketConnectionInitMessage extends WebSocketMessage { - final AWSApiConfig config; - Map<String, dynamic> authorizationHeaders = {}; - - WebSocketConnectionInitMessage(this.config) + WebSocketConnectionInitMessage() : super(messageType: MessageType.connectionInit); +} - Uri getConnectionUri() { - final encodedAuthHeaders = - base64.encode(json.encode(authorizationHeaders).codeUnits); - final endpointUri = Uri.parse( - config.endpoint.replaceFirst('appsync-api', 'appsync-realtime-api')); - return Uri(scheme: 'wss', host: endpointUri.host, path: 'graphql') - .replace(queryParameters: <String, String>{ - 'header': encodedAuthHeaders, - 'payload': - base64.encode(utf8.encode(json.encode({}))) // always payload of '{}' - }); - } +class WebSocketSubscriptionRegistrationMessage extends WebSocketMessage { + WebSocketSubscriptionRegistrationMessage( + {required SubscriptionRegistrationPayload payload}) + : super(messageType: MessageType.start, payload: payload); } diff --git a/packages/api/amplify_api/test/ws/web_socket_connection_test.dart b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart new file mode 100644 index 0000000000..61524efe39 --- /dev/null +++ b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart @@ -0,0 +1,171 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:amplify_api/src/api_plugin_impl.dart'; +import 'package:amplify_api/src/graphql/app_sync_api_key_auth_provider.dart'; +import 'package:amplify_api/src/graphql/ws/websocket_message.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../util.dart'; + +import 'package:amplify_api/src/graphql/ws/websocket_connection.dart'; + +/// Extension of [WebSocketConnection] that stores messages internally instead +/// of sending them. +class MockWebSocketConnection extends WebSocketConnection { + /// Instead of actually connecting, just set the URI here so it can be inspected + /// for testing. + Uri? connectedUri; + + /// Instead of sending messages, they are pushed to end of list so they can be + /// inspected for testing. + final List<WebSocketMessage> sentMessages = []; + + MockWebSocketConnection(super.config, super.authProviderRepo); + + WebSocketMessage? get lastSentMessage => sentMessages.lastOrNull; + + final messageStream = StreamController<dynamic>(); + + @override + Future<void> connect(Uri connectionUri) async { + connectedUri = connectionUri; + + // mock some message responses (acks) from server + final broadcast = messageStream.stream.asBroadcastStream(); + broadcast.listen((event) { + final eventJson = json.decode(event as String); + final messageFromEvent = WebSocketMessage.fromJson(eventJson as Map); + + // connection_init, respond with connection_ack + WebSocketMessage? mockAckMsg; + if (messageFromEvent.messageType == MessageType.connectionInit) { + mockAckMsg = WebSocketMessage( + messageType: MessageType.connectionAck, + payload: ConnectionAckMessagePayload(10000), + ); + // start, respond with start_ack + } else if (messageFromEvent.messageType == MessageType.start) { + mockAckMsg = WebSocketMessage( + messageType: MessageType.startAck, + id: messageFromEvent.id, + ); + } + if (mockAckMsg != null) { + final messageStr = json.encode(mockAckMsg); + messageStream.add(messageStr); + } + }); + + // ensures connected to _onDone events in parent class + getStreamSubscription(broadcast); + } + + @override + Future<void> send(WebSocketMessage message) async { + sentMessages.add(message); + + final messageStr = json.encode(message.toJson()); + messageStream.add(messageStr); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final authProviderRepo = AmplifyAuthProviderRepository(); + authProviderRepo.registerAuthProvider( + APIAuthorizationType.apiKey.authProviderToken, + AppSyncApiKeyAuthProvider()); + + const endpointType = EndpointType.graphQL; + const endpoint = 'https://abc123.appsync-api.us-east-1.amazonaws.com/graphql'; + const region = 'us-east-1'; + const authorizationType = APIAuthorizationType.apiKey; + const apiKey = 'abc-123'; + + const config = AWSApiConfig( + endpointType: endpointType, + endpoint: endpoint, + region: region, + authorizationType: authorizationType, + apiKey: apiKey); + + late MockWebSocketConnection connection; + + const graphQLDocument = '''subscription MySubscription { + onCreateBlog { + id + name + createdAt + } + }'''; + final subscriptionRequest = GraphQLRequest<String>(document: graphQLDocument); + + setUp(() { + connection = MockWebSocketConnection(config, authProviderRepo); + }); + + group('WebSocketConnection', () { + test( + 'init() should connect with authorized query params in URI and send connection init message', + () async { + await connection.init(); + expectLater(connection.ready, completes); + const expectedConnectionUri = + 'wss://abc123.appsync-realtime-api.us-east-1.amazonaws.com/graphql?header=eyJDb250ZW50LVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uOyBjaGFyc2V0PVVURi04IiwiWC1BcGktS2V5IjoiYWJjLTEyMyIsIkFjY2VwdCI6ImFwcGxpY2F0aW9uL2pzb24sIHRleHQvamF2YXNjcmlwdCIsIkNvbnRlbnQtRW5jb2RpbmciOiJhbXotMS4wIiwiSG9zdCI6ImFiYzEyMy5hcHBzeW5jLWFwaS51cy1lYXN0LTEuYW1hem9uYXdzLmNvbSJ9&payload=e30%3D'; + expect(connection.connectedUri.toString(), expectedConnectionUri); + expect( + connection.lastSentMessage?.messageType, MessageType.connectionInit); + }); + + test('subscribe() should initialize the connection and call onEstablished', + () async { + Completer<void> establishedCompleter = Completer(); + connection.subscribe(subscriptionRequest, () { + establishedCompleter.complete(); + }).listen((event) {}); + + expectLater(connection.ready, completes); + expectLater(establishedCompleter.future, completes); + }); + + test( + 'subscribe() should send SubscriptionRegistrationMessage with authorized payload', + () async { + connection.init(); + await connection.ready; + Completer<void> establishedCompleter = Completer(); + connection.subscribe(subscriptionRequest, () { + establishedCompleter.complete(); + }).listen((event) {}); + await establishedCompleter.future; + + final lastMessage = connection.lastSentMessage; + expect(lastMessage?.messageType, MessageType.start); + final payloadJson = lastMessage?.payload?.toJson(); + print(payloadJson); + + // TODO assert payload authorized + }); + + test('subscribe() should return a subscription stream', () {}); + }); +} From dcaeba1a317c6cfa2a5fd29e604ec83ca96a0284 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Tue, 26 Jul 2022 16:35:18 -0700 Subject: [PATCH 11/33] refactor a little --- .../authorize_websocket_message.dart | 65 ----------- .../src/decorators/websocket_auth_utils.dart | 109 ++++++++++++++++++ .../src/graphql/ws/websocket_connection.dart | 54 +++------ .../lib/src/graphql/ws/websocket_message.dart | 7 +- .../test/ws/web_socket_connection_test.dart | 22 ++-- 5 files changed, 147 insertions(+), 110 deletions(-) delete mode 100644 packages/api/amplify_api/lib/src/decorators/authorize_websocket_message.dart create mode 100644 packages/api/amplify_api/lib/src/decorators/websocket_auth_utils.dart diff --git a/packages/api/amplify_api/lib/src/decorators/authorize_websocket_message.dart b/packages/api/amplify_api/lib/src/decorators/authorize_websocket_message.dart deleted file mode 100644 index f4b1d699d1..0000000000 --- a/packages/api/amplify_api/lib/src/decorators/authorize_websocket_message.dart +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. - -import 'package:amplify_core/amplify_core.dart'; -import 'package:http/http.dart' as http; -import 'package:meta/meta.dart'; - -import '../graphql/ws/websocket_message.dart'; -import 'authorize_http_request.dart'; - -/// Takes input websocket message (connection or subscription establisher) and -/// adds authorization headers from auth repo. -@internal -Future<WebSocketSubscriptionRegistrationMessage> authorizeWebSocketMessage( - WebSocketSubscriptionRegistrationMessage inputMessage, - AWSApiConfig config, - AmplifyAuthProviderRepository authRepo, -) async { - final body = inputMessage.payload?.toJson()['data']; - - final payload = inputMessage.payload as SubscriptionRegistrationPayload?; - payload?.authorizationHeaders = await generateAuthorizationHeaders(config, - authRepo: authRepo, body: body as String); - return inputMessage; -} - -@internal -Future<Map<String, String>> generateAuthorizationHeaders( - AWSApiConfig config, { - required AmplifyAuthProviderRepository authRepo, - String body = '{}', -}) async { - final endpointHost = Uri.parse(config.endpoint).host; - // Create canonical HTTP request to authorize. - final maybeConnect = body != '{}' ? '' : '/connect'; - final canonicalHttpRequest = - http.Request('POST', Uri.parse('${config.endpoint}$maybeConnect')); - canonicalHttpRequest.headers.addAll({ - AWSHeaders.accept: 'application/json, text/javascript', - AWSHeaders.contentEncoding: 'amz-1.0', - AWSHeaders.contentType: 'application/json; charset=UTF-8', - }); - canonicalHttpRequest.body = body; - - final authorizedHttpRequest = await authorizeHttpRequest( - canonicalHttpRequest, - endpointConfig: config, - authProviderRepo: authRepo, - ); - return { - ...authorizedHttpRequest.headers, - AWSHeaders.host: endpointHost, - }; -} diff --git a/packages/api/amplify_api/lib/src/decorators/websocket_auth_utils.dart b/packages/api/amplify_api/lib/src/decorators/websocket_auth_utils.dart new file mode 100644 index 0000000000..58092aa224 --- /dev/null +++ b/packages/api/amplify_api/lib/src/decorators/websocket_auth_utils.dart @@ -0,0 +1,109 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:convert'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +import '../graphql/ws/websocket_message.dart'; +import 'authorize_http_request.dart'; + +/// Generate a URI for the connection and all subscriptions. +/// +/// See https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html#handshake-details-to-establish-the-websocket-connection= +@internal +Future<Uri> generateConnectionUri( + AWSApiConfig config, AmplifyAuthProviderRepository authRepo) async { + const body = '{}'; + final authorizationHeaders = await _generateAuthorizationHeaders(config, + authRepo: authRepo, body: body); + final encodedAuthHeaders = + base64.encode(json.encode(authorizationHeaders).codeUnits); + final endpointUri = Uri.parse( + config.endpoint.replaceFirst('appsync-api', 'appsync-realtime-api')); + return Uri(scheme: 'wss', host: endpointUri.host, path: 'graphql') + .replace(queryParameters: <String, String>{ + 'header': encodedAuthHeaders, + 'payload': base64.encode(utf8.encode(body)) // always payload of '{}' + }); +} + +/// Generate websocket message with authorized payload to register subscription. +/// +/// See https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html#subscription-registration-message +@internal +Future<WebSocketSubscriptionRegistrationMessage> + generateSubscriptionRegistrationMessage( + AWSApiConfig config, { + required String id, + required AmplifyAuthProviderRepository authRepo, + required GraphQLRequest request, +}) async { + final body = + jsonEncode({'variables': request.variables, 'query': request.document}); + final authorizationHeaders = await _generateAuthorizationHeaders(config, + authRepo: authRepo, body: body); + + return WebSocketSubscriptionRegistrationMessage( + id: id, + payload: SubscriptionRegistrationPayload( + request: request, + config: config, + authorizationHeaders: authorizationHeaders, + ), + ); +} + +/// For either connection URI or subscription registration, authorization headers +/// are formatted correctly to be either encoded into URI query params or subscription +/// registration payload headers. +/// +/// If body is "{}" then headers are formatted like connection URI. Any other string +/// for body will be formatted as subscription registration. This is done by creating +/// a canonical HTTP request that is authorized but never sent. The headers from +/// the HTTP request are reformatted and returned. This logic applies for all auth +/// modes as determined by [authRepo] parameter. +Future<Map<String, String>> _generateAuthorizationHeaders( + AWSApiConfig config, { + required AmplifyAuthProviderRepository authRepo, + required String body, +}) async { + final endpointHost = Uri.parse(config.endpoint).host; + // Create canonical HTTP request to authorize. + // + // The canonical request URL is a little different depending on if connection_init + // or start (subscription registration). + final maybeConnect = body != '{}' ? '' : '/connect'; + final canonicalHttpRequest = + http.Request('POST', Uri.parse('${config.endpoint}$maybeConnect')); + canonicalHttpRequest.headers.addAll({ + AWSHeaders.accept: 'application/json, text/javascript', + AWSHeaders.contentEncoding: 'amz-1.0', + AWSHeaders.contentType: 'application/json; charset=UTF-8', + }); + canonicalHttpRequest.body = body; + final authorizedHttpRequest = await authorizeHttpRequest( + canonicalHttpRequest, + endpointConfig: config, + authProviderRepo: authRepo, + ); + + // Take authorized HTTP headers as map with "host" value added. + return { + ...authorizedHttpRequest.headers, + AWSHeaders.host: endpointHost, + }; +} diff --git a/packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart b/packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart index 9eeebe95ee..cc2263da45 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import 'package:amplify_api/src/decorators/authorize_websocket_message.dart'; +import 'package:amplify_api/src/decorators/websocket_auth_utils.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:async/async.dart'; import 'package:flutter/material.dart'; @@ -23,7 +23,7 @@ class WebSocketConnection implements Closeable { late final StreamSubscription<WebSocketMessage> _subscription; late final RestartableTimer _timeoutTimer; - // Add connection error variable to throw in `init`. + // TODO: Add connection error variable to throw in `init`. Future<void>? _initFuture; final Completer<void> _connectionReady = Completer<void>(); @@ -42,6 +42,7 @@ class WebSocketConnection implements Closeable { /// {@macro websocket_connection} WebSocketConnection(this._config, this.authProviderRepo); + /// Connects stream to _onData handler. @visibleForTesting StreamSubscription<WebSocketMessage> getStreamSubscription( Stream<dynamic> stream) { @@ -73,9 +74,8 @@ class WebSocketConnection implements Closeable { } Future<void> _init() async { - // Generate a URI for the connection and all subscriptions. - // See https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html#handshake-details-to-establish-the-websocket-connection - final connectionUri = await _getConnectionUri(_config, authProviderRepo); + final connectionUri = + await generateConnectionUri(_config, authProviderRepo); await connect(connectionUri); if (_connectionReady.isCompleted) return; @@ -92,17 +92,22 @@ class WebSocketConnection implements Closeable { if (!_connectionReady.isCompleted) { init(); } - final subRegistration = WebSocketSubscriptionRegistrationMessage( - payload: - SubscriptionRegistrationPayload(request: request, config: _config), - ); - final subscriptionId = subRegistration.id!; + + final subscriptionId = uuid(); return _messageStream .where((msg) => msg.id == subscriptionId) .transform( WebSocketSubscriptionStreamTransformer(request, onEstablished)) .asBroadcastStream( - onListen: (_) => send(subRegistration), + onListen: (_) async { + final subscriptionRegistrationMessage = + await generateSubscriptionRegistrationMessage(_config, + id: subscriptionId, + authRepo: authProviderRepo, + request: request); + + send(subscriptionRegistrationMessage); + }, onCancel: (_) => _cancel(subscriptionId), ); } @@ -113,18 +118,13 @@ class WebSocketConnection implements Closeable { id: subscriptionId, messageType: MessageType.stop, )); - // TODO(equartey): if this is the only susbscription, close the connection. + // TODO(equartey): if this is the only subscription, close the connection. } /// Sends a structured message over the WebSocket. @visibleForTesting - Future<void> send(WebSocketMessage message) async { - var authorizedMessage = message; - if (message is WebSocketSubscriptionRegistrationMessage) { - authorizedMessage = - await authorizeWebSocketMessage(message, _config, authProviderRepo); - } - final msgJson = json.encode(authorizedMessage.toJson()); + void send(WebSocketMessage message) { + final msgJson = json.encode(message.toJson()); _channel.sink.add(msgJson); } @@ -182,19 +182,3 @@ class WebSocketConnection implements Closeable { _rebroadcastController.add(message); } } - -Future<Uri> _getConnectionUri( - AWSApiConfig config, AmplifyAuthProviderRepository authRepo) async { - const body = '{}'; - final authorizationHeaders = await generateAuthorizationHeaders(config, - authRepo: authRepo, body: body); - final encodedAuthHeaders = - base64.encode(json.encode(authorizationHeaders).codeUnits); - final endpointUri = Uri.parse( - config.endpoint.replaceFirst('appsync-api', 'appsync-realtime-api')); - return Uri(scheme: 'wss', host: endpointUri.host, path: 'graphql') - .replace(queryParameters: <String, String>{ - 'header': encodedAuthHeaders, - 'payload': base64.encode(utf8.encode(body)) // always payload of '{}' - }); -} diff --git a/packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart b/packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart index 3729f7cb6e..2522969550 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart @@ -77,11 +77,12 @@ class ConnectionAckMessagePayload extends WebSocketMessagePayload { class SubscriptionRegistrationPayload extends WebSocketMessagePayload { final GraphQLRequest request; final AWSApiConfig config; - Map<String, dynamic> authorizationHeaders = {}; + final Map<String, dynamic> authorizationHeaders; SubscriptionRegistrationPayload({ required this.request, required this.config, + required this.authorizationHeaders, }); @override @@ -186,6 +187,6 @@ class WebSocketConnectionInitMessage extends WebSocketMessage { class WebSocketSubscriptionRegistrationMessage extends WebSocketMessage { WebSocketSubscriptionRegistrationMessage( - {required SubscriptionRegistrationPayload payload}) - : super(messageType: MessageType.start, payload: payload); + {required String id, required SubscriptionRegistrationPayload payload}) + : super(messageType: MessageType.start, payload: payload, id: id); } diff --git a/packages/api/amplify_api/test/ws/web_socket_connection_test.dart b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart index 61524efe39..be0f1f0d91 100644 --- a/packages/api/amplify_api/test/ws/web_socket_connection_test.dart +++ b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart @@ -78,10 +78,10 @@ class MockWebSocketConnection extends WebSocketConnection { getStreamSubscription(broadcast); } + /// Pushes message in sentMessages and adds to stream (to support mocking result). @override - Future<void> send(WebSocketMessage message) async { + void send(WebSocketMessage message) { sentMessages.add(message); - final messageStr = json.encode(message.toJson()); messageStream.add(messageStr); } @@ -148,7 +148,7 @@ void main() { }); test( - 'subscribe() should send SubscriptionRegistrationMessage with authorized payload', + 'subscribe() should send SubscriptionRegistrationMessage with authorized payload correctly serialized', () async { connection.init(); await connection.ready; @@ -161,11 +161,19 @@ void main() { final lastMessage = connection.lastSentMessage; expect(lastMessage?.messageType, MessageType.start); final payloadJson = lastMessage?.payload?.toJson(); - print(payloadJson); - - // TODO assert payload authorized + final apiKeyFromPayload = + payloadJson?['extensions']['authorization'][xApiKey]; + expect(apiKeyFromPayload, apiKey); }); - test('subscribe() should return a subscription stream', () {}); + // test('subscribe() should return a subscription stream', () async { + // connection.init(); + // await connection.ready; + // Completer<void> establishedCompleter = Completer(); + // final subscription = connection.subscribe(subscriptionRequest, () { + // establishedCompleter.complete(); + // }).listen((event) {}); + // await establishedCompleter.future; + // }); }); } From 5715cb5f0b45aa572d7558278811b289589d6078 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Wed, 27 Jul 2022 09:31:45 -0700 Subject: [PATCH 12/33] some renames --- .../amplify_api/lib/src/api_plugin_impl.dart | 2 +- ..._utils.dart => web_socket_auth_utils.dart} | 4 +- ...ection.dart => web_socket_connection.dart} | 78 ++++++++++++------- ...eb_socket_message_stream_transformer.dart} | 16 +++- ...ket_message.dart => web_socket_types.dart} | 55 ++++++++++--- .../test/ws/web_socket_connection_test.dart | 6 +- 6 files changed, 118 insertions(+), 43 deletions(-) rename packages/api/amplify_api/lib/src/decorators/{websocket_auth_utils.dart => web_socket_auth_utils.dart} (97%) rename packages/api/amplify_api/lib/src/graphql/ws/{websocket_connection.dart => web_socket_connection.dart} (66%) rename packages/api/amplify_api/lib/src/graphql/ws/{websocket_message_stream_transformer.dart => web_socket_message_stream_transformer.dart} (70%) rename packages/api/amplify_api/lib/src/graphql/ws/{websocket_message.dart => web_socket_types.dart} (75%) diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index 7a60cdda0e..0a859f4613 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -17,7 +17,7 @@ library amplify_api; import 'dart:io'; import 'package:amplify_api/amplify_api.dart'; -import 'package:amplify_api/src/graphql/ws/websocket_connection.dart'; +import 'package:amplify_api/src/graphql/ws/web_socket_connection.dart'; import 'package:amplify_api/src/native_api_plugin.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:async/async.dart'; diff --git a/packages/api/amplify_api/lib/src/decorators/websocket_auth_utils.dart b/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart similarity index 97% rename from packages/api/amplify_api/lib/src/decorators/websocket_auth_utils.dart rename to packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart index 58092aa224..fb19c1ce2b 100644 --- a/packages/api/amplify_api/lib/src/decorators/websocket_auth_utils.dart +++ b/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart @@ -18,7 +18,7 @@ import 'package:amplify_core/amplify_core.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; -import '../graphql/ws/websocket_message.dart'; +import '../graphql/ws/web_socket_types.dart'; import 'authorize_http_request.dart'; /// Generate a URI for the connection and all subscriptions. @@ -82,7 +82,7 @@ Future<Map<String, String>> _generateAuthorizationHeaders( required String body, }) async { final endpointHost = Uri.parse(config.endpoint).host; - // Create canonical HTTP request to authorize. + // Create canonical HTTP request to authorize but never send. // // The canonical request URL is a little different depending on if connection_init // or start (subscription registration). diff --git a/packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart similarity index 66% rename from packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart rename to packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart index cc2263da45..98ccaeaea8 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/websocket_connection.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart @@ -1,30 +1,58 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + import 'dart:async'; import 'dart:convert'; -import 'package:amplify_api/src/decorators/websocket_auth_utils.dart'; +import 'package:amplify_api/src/decorators/web_socket_auth_utils.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:async/async.dart'; -import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -import 'websocket_message.dart'; -import 'websocket_message_stream_transformer.dart'; +import 'web_socket_message_stream_transformer.dart'; +import 'web_socket_types.dart'; -/// {@template websocket_connection} +/// {@template amplify_api.web_socket_connection} /// Manages connection with an AppSync backend and subscription routing. /// {@endtemplate} +@internal class WebSocketConnection implements Closeable { + /// Allowed protocols for this connection. static const webSocketProtocols = ['graphql-ws']; - final AmplifyAuthProviderRepository authProviderRepo; - + // Config and auth repo together determine how to authorize connection URLs + // and subscription registration messages. + final AmplifyAuthProviderRepository _authProviderRepo; final AWSApiConfig _config; + + // Manages all incoming messages from server. Primarily handles messages related + // to the entire connection. E.g. connection_ack, connection_error, ka, error. + // Other events (for single subscriptions) rebroadcast to _rebroadcastController. late final WebSocketChannel _channel; late final StreamSubscription<WebSocketMessage> _subscription; late final RestartableTimer _timeoutTimer; + // Re-broadcasts incoming messages for child streams (single GraphQL subscriptions). + // start_ack, data, error + final StreamController<WebSocketMessage> _rebroadcastController = + StreamController<WebSocketMessage>.broadcast(); + Stream<WebSocketMessage> get _messageStream => _rebroadcastController.stream; + // TODO: Add connection error variable to throw in `init`. + // Futures to manage initial connection state. Future<void>? _initFuture; final Completer<void> _connectionReady = Completer<void>(); @@ -32,17 +60,10 @@ class WebSocketConnection implements Closeable { /// after the first `connection_ack` message. Future<void> get ready => _connectionReady.future; - /// Re-broadcasts messages for child streams. - final StreamController<WebSocketMessage> _rebroadcastController = - StreamController<WebSocketMessage>.broadcast(); - - /// Incoming message stream for all events. - Stream<WebSocketMessage> get _messageStream => _rebroadcastController.stream; - - /// {@macro websocket_connection} - WebSocketConnection(this._config, this.authProviderRepo); + /// {@macro amplify_api.web_socket_connection} + WebSocketConnection(this._config, this._authProviderRepo); - /// Connects stream to _onData handler. + /// Connects _subscription stream to _onData handler. @visibleForTesting StreamSubscription<WebSocketMessage> getStreamSubscription( Stream<dynamic> stream) { @@ -51,7 +72,8 @@ class WebSocketConnection implements Closeable { .listen(_onData); } - /// Connects to the real time WebSocket. + /// Connects WebSocket channel to _subscription stream but does not send connection + /// init message. @visibleForTesting Future<void> connect(Uri connectionUri) async { _channel = WebSocketChannel.connect( @@ -69,13 +91,16 @@ class WebSocketConnection implements Closeable { } /// Initializes the connection. + /// + /// Connects to WebSocket, sends connection message and resolves future once + /// connection_ack message received from server. Future<void> init() { return _initFuture ??= _init(); } Future<void> _init() async { final connectionUri = - await generateConnectionUri(_config, authProviderRepo); + await generateConnectionUri(_config, _authProviderRepo); await connect(connectionUri); if (_connectionReady.isCompleted) return; @@ -100,10 +125,11 @@ class WebSocketConnection implements Closeable { WebSocketSubscriptionStreamTransformer(request, onEstablished)) .asBroadcastStream( onListen: (_) async { + // Callout: need to reconsider sending start message onListen. final subscriptionRegistrationMessage = await generateSubscriptionRegistrationMessage(_config, id: subscriptionId, - authRepo: authProviderRepo, + authRepo: _authProviderRepo, request: request); send(subscriptionRegistrationMessage); @@ -114,14 +140,11 @@ class WebSocketConnection implements Closeable { /// Cancels a subscription. void _cancel(String subscriptionId) { - send(WebSocketMessage( - id: subscriptionId, - messageType: MessageType.stop, - )); + send(WebSocketStopMessage(id: subscriptionId)); // TODO(equartey): if this is the only subscription, close the connection. } - /// Sends a structured message over the WebSocket. + /// Serializes a message as JSON string and sends over WebSocket channel. @visibleForTesting void send(WebSocketMessage message) { final msgJson = json.encode(message.toJson()); @@ -139,6 +162,9 @@ class WebSocketConnection implements Closeable { } /// Handles incoming data on the WebSocket. + /// + /// Here, handle connection-wide messages and pass subscription events to + /// `_rebroadcastController`. void _onData(WebSocketMessage message) { switch (message.messageType) { case MessageType.connectionAck: @@ -178,7 +204,7 @@ class WebSocketConnection implements Closeable { break; } - // Re-broadcast unhandled messages + // Re-broadcast other message types related to single subscriptions. _rebroadcastController.add(message); } } diff --git a/packages/api/amplify_api/lib/src/graphql/ws/websocket_message_stream_transformer.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart similarity index 70% rename from packages/api/amplify_api/lib/src/graphql/ws/websocket_message_stream_transformer.dart rename to packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart index 90afd0d429..ea6c195d67 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/websocket_message_stream_transformer.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart @@ -1,11 +1,25 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + import 'dart:async'; import 'dart:convert'; import 'package:amplify_api/src/util.dart'; import 'package:amplify_core/amplify_core.dart'; -import 'websocket_message.dart'; import '../graphql_response_decoder.dart'; +import 'web_socket_types.dart'; class WebSocketMessageStreamTransformer extends StreamTransformerBase<dynamic, WebSocketMessage> { diff --git a/packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart similarity index 75% rename from packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart rename to packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart index 2522969550..1db9caf8f7 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/websocket_message.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart @@ -1,7 +1,25 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +// ignore_for_file: public_member_api_docs + import 'dart:convert'; import 'package:amplify_core/amplify_core.dart'; +import 'package:meta/meta.dart'; +@internal class MessageType { final String type; @@ -38,8 +56,9 @@ class MessageType { String toString() => type; } +@internal abstract class WebSocketMessagePayload { - WebSocketMessagePayload(); + const WebSocketMessagePayload(); static const Map<MessageType, WebSocketMessagePayload Function(Map)> _factories = { @@ -58,10 +77,11 @@ abstract class WebSocketMessagePayload { String toString() => prettyPrintJson(toJson()); } +@internal class ConnectionAckMessagePayload extends WebSocketMessagePayload { final int connectionTimeoutMs; - ConnectionAckMessagePayload(this.connectionTimeoutMs); + const ConnectionAckMessagePayload(this.connectionTimeoutMs); static ConnectionAckMessagePayload fromJson(Map json) { final connectionTimeoutMs = json['connectionTimeoutMs'] as int; @@ -74,27 +94,31 @@ class ConnectionAckMessagePayload extends WebSocketMessagePayload { }; } +@internal class SubscriptionRegistrationPayload extends WebSocketMessagePayload { final GraphQLRequest request; final AWSApiConfig config; - final Map<String, dynamic> authorizationHeaders; + final Map<String, String> authorizationHeaders; - SubscriptionRegistrationPayload({ + const SubscriptionRegistrationPayload({ required this.request, required this.config, required this.authorizationHeaders, }); @override - Map<String, dynamic> toJson() { - return <String, dynamic>{ + Map<String, Object> toJson() { + return <String, Object>{ 'data': jsonEncode( {'variables': request.variables, 'query': request.document}), - 'extensions': <String, dynamic>{'authorization': authorizationHeaders} + 'extensions': <String, Map<String, String>>{ + 'authorization': authorizationHeaders + } }; } } +@internal class SubscriptionDataPayload extends WebSocketMessagePayload { final Map<String, dynamic>? data; final Map<String, dynamic>? errors; @@ -117,6 +141,7 @@ class SubscriptionDataPayload extends WebSocketMessagePayload { }; } +@internal class WebSocketError extends WebSocketMessagePayload implements Exception { final List<Map> errors; @@ -133,6 +158,7 @@ class WebSocketError extends WebSocketMessagePayload implements Exception { }; } +@internal class WebSocketMessage { final String? id; final MessageType messageType; @@ -180,13 +206,22 @@ class WebSocketMessage { } } +@internal class WebSocketConnectionInitMessage extends WebSocketMessage { WebSocketConnectionInitMessage() : super(messageType: MessageType.connectionInit); } +@internal class WebSocketSubscriptionRegistrationMessage extends WebSocketMessage { - WebSocketSubscriptionRegistrationMessage( - {required String id, required SubscriptionRegistrationPayload payload}) - : super(messageType: MessageType.start, payload: payload, id: id); + WebSocketSubscriptionRegistrationMessage({ + required String id, + required SubscriptionRegistrationPayload payload, + }) : super(messageType: MessageType.start, payload: payload, id: id); +} + +@internal +class WebSocketStopMessage extends WebSocketMessage { + WebSocketStopMessage({required String id}) + : super(messageType: MessageType.stop, id: id); } diff --git a/packages/api/amplify_api/test/ws/web_socket_connection_test.dart b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart index be0f1f0d91..a18a63e582 100644 --- a/packages/api/amplify_api/test/ws/web_socket_connection_test.dart +++ b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart @@ -17,7 +17,7 @@ import 'dart:convert'; import 'package:amplify_api/src/api_plugin_impl.dart'; import 'package:amplify_api/src/graphql/app_sync_api_key_auth_provider.dart'; -import 'package:amplify_api/src/graphql/ws/websocket_message.dart'; +import 'package:amplify_api/src/graphql/ws/web_socket_types.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:collection/collection.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -25,7 +25,7 @@ import 'package:web_socket_channel/web_socket_channel.dart'; import '../util.dart'; -import 'package:amplify_api/src/graphql/ws/websocket_connection.dart'; +import 'package:amplify_api/src/graphql/ws/web_socket_connection.dart'; /// Extension of [WebSocketConnection] that stores messages internally instead /// of sending them. @@ -59,7 +59,7 @@ class MockWebSocketConnection extends WebSocketConnection { if (messageFromEvent.messageType == MessageType.connectionInit) { mockAckMsg = WebSocketMessage( messageType: MessageType.connectionAck, - payload: ConnectionAckMessagePayload(10000), + payload: const ConnectionAckMessagePayload(10000), ); // start, respond with start_ack } else if (messageFromEvent.messageType == MessageType.start) { From db28cab2d1558a511a76f83381255050b5876bfe Mon Sep 17 00:00:00 2001 From: Elijah Quartey <Equartey@users.noreply.github.com> Date: Fri, 29 Jul 2022 09:31:47 -0500 Subject: [PATCH 13/33] feat(api): GraphQL Custom Request Headers (#1938) --- .../lib/src/types/api/graphql/graphql_request.dart | 5 +++++ .../amplify_api/lib/src/graphql/send_graphql_request.dart | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/amplify_core/lib/src/types/api/graphql/graphql_request.dart b/packages/amplify_core/lib/src/types/api/graphql/graphql_request.dart index ff77c1713c..26778fdee7 100644 --- a/packages/amplify_core/lib/src/types/api/graphql/graphql_request.dart +++ b/packages/amplify_core/lib/src/types/api/graphql/graphql_request.dart @@ -22,6 +22,9 @@ class GraphQLRequest<T> { /// Only required if your backend has multiple GraphQL endpoints in the amplifyconfiguration.dart file. This parameter is then needed to specify which one to use for this request. final String? apiName; + /// A map of Strings to dynamically use for custom headers in the http request. + final Map<String, String>? headers; + /// The body of the request, starting with the operation type and operation name. /// /// See https://graphql.org/learn/queries/#operation-name for examples and more information. @@ -57,12 +60,14 @@ class GraphQLRequest<T> { {this.apiName, required this.document, this.variables = const <String, dynamic>{}, + this.headers, this.decodePath, this.modelType}); Map<String, dynamic> serializeAsMap() => <String, dynamic>{ 'document': document, 'variables': variables, + 'headers': headers, 'cancelToken': id, if (apiName != null) 'apiName': apiName, }; diff --git a/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart b/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart index 6eab7deadd..3ba0a36c7d 100644 --- a/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart +++ b/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart @@ -31,7 +31,8 @@ Future<GraphQLResponse<T>> sendGraphQLRequest<T>({ }) async { try { final body = {'variables': request.variables, 'query': request.document}; - final graphQLResponse = await client.post(uri, body: json.encode(body)); + final graphQLResponse = await client.post(uri, + body: json.encode(body), headers: request.headers); final responseBody = json.decode(graphQLResponse.body); From c8297a6463a1a7c6ccf49bec733d5ec36bab2b49 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Mon, 8 Aug 2022 08:58:01 -0800 Subject: [PATCH 14/33] feat(auth,api): cognito user pools auth provider & auth mode for API HTTP requests (#1913) --- .../decorators/authorize_http_request.dart | 9 +- .../test/authorize_http_request_test.dart | 34 ++- packages/api/amplify_api/test/util.dart | 9 + .../lib/src/auth_plugin_impl.dart | 14 +- .../cognito_user_pools_auth_provider.dart | 37 +++ .../test/plugin/auth_providers_test.dart | 210 ++++++++++++++---- 6 files changed, 255 insertions(+), 58 deletions(-) create mode 100644 packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_user_pools_auth_provider.dart diff --git a/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart b/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart index 3cab4d7443..24a343895e 100644 --- a/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart +++ b/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart @@ -64,8 +64,15 @@ Future<http.BaseRequest> authorizeHttpRequest(http.BaseRequest request, return authorizedRequest.httpRequest; case APIAuthorizationType.function: case APIAuthorizationType.oidc: - case APIAuthorizationType.userPools: throw UnimplementedError('${authType.name} not implemented.'); + case APIAuthorizationType.userPools: + final authProvider = _validateAuthProvider( + authProviderRepo.getAuthProvider(authType.authProviderToken), + authType, + ); + final authorizedRequest = + await authProvider.authorizeRequest(_httpToAWSRequest(request)); + return authorizedRequest.httpRequest; case APIAuthorizationType.none: return request; } diff --git a/packages/api/amplify_api/test/authorize_http_request_test.dart b/packages/api/amplify_api/test/authorize_http_request_test.dart index 2179a07ad8..3f1ad3754d 100644 --- a/packages/api/amplify_api/test/authorize_http_request_test.dart +++ b/packages/api/amplify_api/test/authorize_http_request_test.dart @@ -33,11 +33,19 @@ void main() { final authProviderRepo = AmplifyAuthProviderRepository(); setUpAll(() { - authProviderRepo.registerAuthProvider( + authProviderRepo + ..registerAuthProvider( APIAuthorizationType.apiKey.authProviderToken, - AppSyncApiKeyAuthProvider()); - authProviderRepo.registerAuthProvider( - APIAuthorizationType.iam.authProviderToken, TestIamAuthProvider()); + AppSyncApiKeyAuthProvider(), + ) + ..registerAuthProvider( + APIAuthorizationType.iam.authProviderToken, + TestIamAuthProvider(), + ) + ..registerAuthProvider( + APIAuthorizationType.userPools.authProviderToken, + TestTokenAuthProvider(), + ); }); group('authorizeHttpRequest', () { @@ -132,7 +140,23 @@ void main() { throwsA(isA<ApiException>())); }); - test('authorizes with Cognito User Pools auth mode', () {}, skip: true); + test('authorizes with Cognito User Pools auth mode', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.userPools, + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + expect( + authorizedRequest.headers[AWSHeaders.authorization], + testAccessToken, + ); + }); test('authorizes with OIDC auth mode', () {}, skip: true); diff --git a/packages/api/amplify_api/test/util.dart b/packages/api/amplify_api/test/util.dart index f3c2ef551e..cd06f8c13c 100644 --- a/packages/api/amplify_api/test/util.dart +++ b/packages/api/amplify_api/test/util.dart @@ -17,6 +17,8 @@ import 'package:aws_signature_v4/aws_signature_v4.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; +const testAccessToken = 'test-access-token-123'; + class TestIamAuthProvider extends AWSIamAmplifyAuthProvider { @override Future<AWSCredentials> retrieve() async { @@ -43,6 +45,13 @@ class TestIamAuthProvider extends AWSIamAmplifyAuthProvider { } } +class TestTokenAuthProvider extends TokenAmplifyAuthProvider { + @override + Future<String> getLatestAuthToken() async { + return testAccessToken; + } +} + void validateSignedRequest(http.BaseRequest request) { const userAgentHeader = zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart index bec1774a51..89ee79adce 100644 --- a/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart @@ -51,6 +51,7 @@ import 'package:amplify_auth_cognito_dart/src/sdk/cognito_identity_provider.dart import 'package:amplify_auth_cognito_dart/src/sdk/sdk_bridge.dart'; import 'package:amplify_auth_cognito_dart/src/state/state.dart'; import 'package:amplify_auth_cognito_dart/src/util/cognito_iam_auth_provider.dart'; +import 'package:amplify_auth_cognito_dart/src/util/cognito_user_pools_auth_provider.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:amplify_secure_storage_dart/amplify_secure_storage_dart.dart'; import 'package:built_collection/built_collection.dart'; @@ -180,10 +181,15 @@ class AmplifyAuthCognitoDart extends AuthPluginInterface // Register auth providers to provide auth functionality to other plugins // without requiring other plugins to call `Amplify.Auth...` directly. - authProviderRepo.registerAuthProvider( - APIAuthorizationType.iam.authProviderToken, - CognitoIamAuthProvider(), - ); + authProviderRepo + ..registerAuthProvider( + APIAuthorizationType.iam.authProviderToken, + CognitoIamAuthProvider(), + ) + ..registerAuthProvider( + APIAuthorizationType.userPools.authProviderToken, + CognitoUserPoolsAuthProvider(), + ); if (_stateMachine.getOrCreate(AuthStateMachine.type).currentState.type != AuthStateType.notConfigured) { diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_user_pools_auth_provider.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_user_pools_auth_provider.dart new file mode 100644 index 0000000000..edde7c3bca --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_user_pools_auth_provider.dart @@ -0,0 +1,37 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:async'; + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:meta/meta.dart'; + +/// [AmplifyAuthProvider] implementation that adds access token to request headers. +@internal +class CognitoUserPoolsAuthProvider extends TokenAmplifyAuthProvider { + /// Get access token from `Amplify.Auth.fetchAuthSession()`. + @override + Future<String> getLatestAuthToken() async { + final authSession = + await Amplify.Auth.fetchAuthSession() as CognitoAuthSession; + final token = authSession.userPoolTokens?.accessToken.raw; + if (token == null) { + throw const AuthException( + 'Unable to fetch access token while authorizing with Cognito User Pools.', + ); + } + return token; + } +} diff --git a/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart b/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart index acb126fa66..de1d20b496 100644 --- a/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart +++ b/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart @@ -15,7 +15,9 @@ import 'dart:async'; import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart' hide InternalErrorException; +import 'package:amplify_auth_cognito_dart/src/credentials/cognito_keys.dart'; import 'package:amplify_auth_cognito_dart/src/util/cognito_iam_auth_provider.dart'; +import 'package:amplify_auth_cognito_dart/src/util/cognito_user_pools_auth_provider.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:test/test.dart'; @@ -29,84 +31,196 @@ AWSHttpRequest _generateTestRequest() { ); } -/// Returns dummy AWS credentials. -class TestAmplifyAuth extends AmplifyAuthCognitoDart { +/// Mock implementation of user pool only error when trying to get credentials. +class TestAmplifyAuthUserPoolOnly extends AmplifyAuthCognitoDart { @override Future<AuthSession> fetchAuthSession({ required AuthSessionRequest request, }) async { - return const CognitoAuthSession( + final options = request.options as CognitoSessionOptions?; + final getAWSCredentials = options?.getAWSCredentials; + if (getAWSCredentials != null && getAWSCredentials) { + throw const InvalidAccountTypeException.noIdentityPool( + recoverySuggestion: + 'Register an identity pool using the CLI or set getAWSCredentials ' + 'to false', + ); + } + return CognitoAuthSession( isSignedIn: true, - credentials: AWSCredentials('fakeKeyId', 'fakeSecret'), + userPoolTokens: CognitoUserPoolTokens( + accessToken: accessToken, + idToken: idToken, + refreshToken: refreshToken, + ), ); } } void main() { + late AmplifyAuthCognitoDart plugin; + late AmplifyAuthProviderRepository testAuthRepo; + + setUpAll(() async { + testAuthRepo = AmplifyAuthProviderRepository(); + final secureStorage = MockSecureStorage(); + final stateMachine = CognitoAuthStateMachine()..addInstance(secureStorage); + plugin = AmplifyAuthCognitoDart(credentialStorage: secureStorage) + ..stateMachine = stateMachine; + + seedStorage( + secureStorage, + userPoolKeys: CognitoUserPoolKeys(userPoolConfig), + identityPoolKeys: CognitoIdentityPoolKeys(identityPoolConfig), + ); + + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + }); + group( 'AmplifyAuthCognitoDart plugin registers auth providers during configuration', () { - late AmplifyAuthCognitoDart plugin; - - setUp(() async { - plugin = AmplifyAuthCognitoDart(credentialStorage: MockSecureStorage()); - }); - test('registers CognitoIamAuthProvider', () async { - final testAuthRepo = AmplifyAuthProviderRepository(); - await plugin.configure( - config: mockConfig, - authProviderRepo: testAuthRepo, - ); final authProvider = testAuthRepo.getAuthProvider( APIAuthorizationType.iam.authProviderToken, ); expect(authProvider, isA<CognitoIamAuthProvider>()); }); - }); - - group('CognitoIamAuthProvider', () { - setUpAll(() async { - await Amplify.addPlugin(TestAmplifyAuth()); - }); - test('gets AWS credentials from Amplify.Auth.fetchAuthSession', () async { - final authProvider = CognitoIamAuthProvider(); - final credentials = await authProvider.retrieve(); - expect(credentials.accessKeyId, isA<String>()); - expect(credentials.secretAccessKey, isA<String>()); + test('registers CognitoUserPoolsAuthProvider', () async { + final authProvider = testAuthRepo.getAuthProvider( + APIAuthorizationType.userPools.authProviderToken, + ); + expect(authProvider, isA<CognitoUserPoolsAuthProvider>()); }); + }); - test('signs a request when calling authorizeRequest', () async { + group('no auth plugin added', () { + test('CognitoIamAuthProvider throws when trying to authorize a request', + () async { final authProvider = CognitoIamAuthProvider(); - final authorizedRequest = await authProvider.authorizeRequest( - _generateTestRequest(), - options: const IamAuthProviderOptions( - region: 'us-east-1', - service: AWSService.appSync, + await expectLater( + authProvider.authorizeRequest( + _generateTestRequest(), + options: const IamAuthProviderOptions( + region: 'us-east-1', + service: AWSService.appSync, + ), ), - ); - // Note: not intended to be complete test of sigv4 algorithm. - expect(authorizedRequest.headers[AWSHeaders.authorization], isNotEmpty); - const userAgentHeader = - zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; - expect( - authorizedRequest.headers[AWSHeaders.host], - isNotEmpty, - skip: zIsWeb, - ); - expect( - authorizedRequest.headers[userAgentHeader], - contains('aws-sigv4'), + throwsA(isA<AmplifyException>()), ); }); - test('throws when no options provided', () async { - final authProvider = CognitoIamAuthProvider(); + test('CognitoUserPoolsAuthProvider throws when trying to authorize request', + () async { + final authProvider = CognitoUserPoolsAuthProvider(); await expectLater( authProvider.authorizeRequest(_generateTestRequest()), - throwsA(isA<AuthException>()), + throwsA(isA<AmplifyException>()), ); }); }); + + group('auth providers defined in auth plugin', () { + setUpAll(() async { + await Amplify.reset(); + await Amplify.addPlugin(plugin); + }); + + group('CognitoIamAuthProvider', () { + test('gets AWS credentials from Amplify.Auth.fetchAuthSession', () async { + final authProvider = CognitoIamAuthProvider(); + final credentials = await authProvider.retrieve(); + expect(credentials.accessKeyId, isA<String>()); + expect(credentials.secretAccessKey, isA<String>()); + }); + + test('signs a request when calling authorizeRequest', () async { + final authProvider = CognitoIamAuthProvider(); + final authorizedRequest = await authProvider.authorizeRequest( + _generateTestRequest(), + options: const IamAuthProviderOptions( + region: 'us-east-1', + service: AWSService.appSync, + ), + ); + // Note: not intended to be complete test of sigv4 algorithm. + expect(authorizedRequest.headers[AWSHeaders.authorization], isNotEmpty); + const userAgentHeader = + zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; + expect( + authorizedRequest.headers[AWSHeaders.host], + isNotEmpty, + skip: zIsWeb, + ); + expect( + authorizedRequest.headers[userAgentHeader], + contains('aws-sigv4'), + ); + }); + + test('throws when no options provided', () async { + final authProvider = CognitoIamAuthProvider(); + await expectLater( + authProvider.authorizeRequest(_generateTestRequest()), + throwsA(isA<AuthException>()), + ); + }); + }); + + group('CognitoUserPoolsAuthProvider', () { + test('gets raw access token from Amplify.Auth.fetchAuthSession', + () async { + final authProvider = CognitoUserPoolsAuthProvider(); + final token = await authProvider.getLatestAuthToken(); + expect(token, accessToken.raw); + }); + + test('adds access token to header when calling authorizeRequest', + () async { + final authProvider = CognitoUserPoolsAuthProvider(); + final authorizedRequest = await authProvider.authorizeRequest( + _generateTestRequest(), + ); + expect( + authorizedRequest.headers[AWSHeaders.authorization], + accessToken.raw, + ); + }); + }); + }); + + group('auth providers with user pool-only configuration', () { + setUpAll(() async { + await Amplify.reset(); + await Amplify.addPlugin(TestAmplifyAuthUserPoolOnly()); + }); + + group('CognitoIamAuthProvider', () { + test('throws when trying to retrieve credentials', () async { + final authProvider = CognitoIamAuthProvider(); + await expectLater( + authProvider.retrieve(), + throwsA(isA<InvalidAccountTypeException>()), + ); + }); + }); + + group('CognitoUserPoolsAuthProvider', () { + test('adds access token to header when calling authorizeRequest', + () async { + final authProvider = CognitoUserPoolsAuthProvider(); + final authorizedRequest = await authProvider.authorizeRequest( + _generateTestRequest(), + ); + expect( + authorizedRequest.headers[AWSHeaders.authorization], + accessToken.raw, + ); + }); + }); + }); } From c464e6e4e90284af48c45601ec51e1910015be5a Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Wed, 10 Aug 2022 13:13:33 -0700 Subject: [PATCH 15/33] change disconnect --- .../example/lib/graphql_api_view.dart | 20 ++++++--- .../src/graphql/ws/web_socket_connection.dart | 44 ++++++++++--------- .../lib/src/graphql/ws/web_socket_types.dart | 2 + 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/packages/api/amplify_api/example/lib/graphql_api_view.dart b/packages/api/amplify_api/example/lib/graphql_api_view.dart index 6644dad380..fa0f2f345f 100644 --- a/packages/api/amplify_api/example/lib/graphql_api_view.dart +++ b/packages/api/amplify_api/example/lib/graphql_api_view.dart @@ -45,13 +45,19 @@ class _GraphQLApiViewState extends State<GraphQLApiView> { onEstablished: () => print('Subscription established'), ); - try { - await for (var event in operation) { - print('Subscription event data received: ${event.data}'); - } - } on Exception catch (e) { - print('Error in subscription stream: $e'); - } + final streamSubscription = operation.listen( + (event) { + final result = 'Subscription event data received: ${event.data}'; + print(result); + setState(() { + _result = result; + }); + }, + onError: (Object error) => print( + 'Error in GraphQL subscription: $error', + ), + ); + _unsubscribe = streamSubscription.cancel; } Future<void> query() async { diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart index 98ccaeaea8..eb0fc99e2f 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart @@ -19,11 +19,15 @@ import 'package:amplify_api/src/decorators/web_socket_auth_utils.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:async/async.dart'; import 'package:meta/meta.dart'; +import 'package:web_socket_channel/status.dart' as status; import 'package:web_socket_channel/web_socket_channel.dart'; import 'web_socket_message_stream_transformer.dart'; import 'web_socket_types.dart'; +/// 1001, going away +const _defaultCloseStatus = status.goingAway; + /// {@template amplify_api.web_socket_connection} /// Manages connection with an AppSync backend and subscription routing. /// {@endtemplate} @@ -53,7 +57,7 @@ class WebSocketConnection implements Closeable { // TODO: Add connection error variable to throw in `init`. // Futures to manage initial connection state. - Future<void>? _initFuture; + final _initMemo = AsyncMemoizer<void>(); final Completer<void> _connectionReady = Completer<void>(); /// Fires when the connection is ready to be listened to, i.e. @@ -85,18 +89,21 @@ class WebSocketConnection implements Closeable { /// Closes the WebSocket connection. @override - void close() { + void close([int closeStatus = _defaultCloseStatus]) { + final reason = + closeStatus == _defaultCloseStatus ? 'client closed' : 'unknown'; _subscription.cancel(); - _channel.sink.close(); + _channel.sink.close(closeStatus, reason); + _rebroadcastController.close(); + _timeoutTimer.cancel(); } /// Initializes the connection. /// /// Connects to WebSocket, sends connection message and resolves future once - /// connection_ack message received from server. - Future<void> init() { - return _initFuture ??= _init(); - } + /// connection_ack message received from server. If the connection was previously + /// established then will return previously completed future. + Future<void> init() => _initMemo.runOnce(_init); Future<void> _init() async { final connectionUri = @@ -105,6 +112,7 @@ class WebSocketConnection implements Closeable { if (_connectionReady.isCompleted) return; send(WebSocketConnectionInitMessage()); + return ready; } @@ -119,23 +127,19 @@ class WebSocketConnection implements Closeable { } final subscriptionId = uuid(); + + generateSubscriptionRegistrationMessage( + _config, + id: subscriptionId, + authRepo: _authProviderRepo, + request: request, + ).then(send); + return _messageStream .where((msg) => msg.id == subscriptionId) .transform( WebSocketSubscriptionStreamTransformer(request, onEstablished)) - .asBroadcastStream( - onListen: (_) async { - // Callout: need to reconsider sending start message onListen. - final subscriptionRegistrationMessage = - await generateSubscriptionRegistrationMessage(_config, - id: subscriptionId, - authRepo: _authProviderRepo, - request: request); - - send(subscriptionRegistrationMessage); - }, - onCancel: (_) => _cancel(subscriptionId), - ); + .asBroadcastStream(onCancel: (_) => _cancel(subscriptionId)); } /// Cancels a subscription. diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart index 1db9caf8f7..0a754122bd 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart @@ -56,6 +56,7 @@ class MessageType { String toString() => type; } +@immutable @internal abstract class WebSocketMessagePayload { const WebSocketMessagePayload(); @@ -158,6 +159,7 @@ class WebSocketError extends WebSocketMessagePayload implements Exception { }; } +@immutable @internal class WebSocketMessage { final String? id; From d93e00c5e456ea22ad1ba1fec2d5bcafa883799b Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Wed, 10 Aug 2022 14:12:12 -0700 Subject: [PATCH 16/33] add logger --- .../amplify_api/lib/src/api_plugin_impl.dart | 6 ++++- .../src/graphql/ws/web_socket_connection.dart | 23 ++++++++++++++----- ...web_socket_message_stream_transformer.dart | 19 +++++++++++---- .../test/ws/web_socket_connection_test.dart | 4 +++- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index 0a859f4613..a6a574b5e2 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -38,6 +38,7 @@ class AmplifyAPIDart extends AmplifyAPI { late final AWSApiPluginConfig _apiConfig; final http.Client? _baseHttpClient; late final AmplifyAuthProviderRepository _authProviderRepo; + final _logger = AmplifyLogger.category(Category.api); /// A map of the keys from the Amplify API config to HTTP clients to use for /// requests to that endpoint. @@ -138,7 +139,10 @@ class AmplifyAPIDart extends AmplifyAPI { apiName: apiName, ); return _webSocketConnectionPool[endpoint.name] ??= - WebSocketConnection(endpoint.config, _authProviderRepo); + WebSocketConnection(endpoint.config, _authProviderRepo, + logger: _logger.createChild( + 'webSocketConnection${endpoint.name}', + )); } Uri _getGraphQLUri(String? apiName) { diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart index eb0fc99e2f..9a1f2354d3 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart @@ -35,6 +35,7 @@ const _defaultCloseStatus = status.goingAway; class WebSocketConnection implements Closeable { /// Allowed protocols for this connection. static const webSocketProtocols = ['graphql-ws']; + final AmplifyLogger _logger; // Config and auth repo together determine how to authorize connection URLs // and subscription registration messages. @@ -65,7 +66,9 @@ class WebSocketConnection implements Closeable { Future<void> get ready => _connectionReady.future; /// {@macro amplify_api.web_socket_connection} - WebSocketConnection(this._config, this._authProviderRepo); + WebSocketConnection(this._config, this._authProviderRepo, + {required AmplifyLogger logger}) + : _logger = logger; /// Connects _subscription stream to _onData handler. @visibleForTesting @@ -126,8 +129,8 @@ class WebSocketConnection implements Closeable { init(); } + // Generate and send an authorized subscription registration message. final subscriptionId = uuid(); - generateSubscriptionRegistrationMessage( _config, id: subscriptionId, @@ -135,15 +138,21 @@ class WebSocketConnection implements Closeable { request: request, ).then(send); + // Filter incoming messages that have the subscription ID and return as new + // stream with messages converted to GraphQLResponse<T>. return _messageStream .where((msg) => msg.id == subscriptionId) - .transform( - WebSocketSubscriptionStreamTransformer(request, onEstablished)) + .transform(WebSocketSubscriptionStreamTransformer( + request, + onEstablished, + logger: _logger, + )) .asBroadcastStream(onCancel: (_) => _cancel(subscriptionId)); } /// Cancels a subscription. void _cancel(String subscriptionId) { + _logger.info('Attempting to cancel Operation $subscriptionId'); send(WebSocketStopMessage(id: subscriptionId)); // TODO(equartey): if this is the only subscription, close the connection. } @@ -170,6 +179,8 @@ class WebSocketConnection implements Closeable { /// Here, handle connection-wide messages and pass subscription events to /// `_rebroadcastController`. void _onData(WebSocketMessage message) { + _logger.verbose('websocket received message: $message'); + switch (message.messageType) { case MessageType.connectionAck: final messageAck = message.payload as ConnectionAckMessagePayload; @@ -181,7 +192,7 @@ class WebSocketConnection implements Closeable { () => _timeout(timeoutDuration), ); _connectionReady.complete(); - // print('Registered timer'); + _logger.verbose('Connection established. Registered timer'); return; case MessageType.connectionError: final wsError = message.payload as WebSocketError?; @@ -194,7 +205,7 @@ class WebSocketConnection implements Closeable { return; case MessageType.keepAlive: _timeoutTimer.reset(); - // print('Reset timer'); + _logger.verbose('Reset timer'); return; case MessageType.error: // Only handle general messages, not subscription-specific ones diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart index ea6c195d67..72d9c7160a 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart @@ -12,15 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. +// ignore_for_file: public_member_api_docs + import 'dart:async'; import 'dart:convert'; import 'package:amplify_api/src/util.dart'; import 'package:amplify_core/amplify_core.dart'; +import 'package:meta/meta.dart'; import '../graphql_response_decoder.dart'; import 'web_socket_types.dart'; +@internal class WebSocketMessageStreamTransformer extends StreamTransformerBase<dynamic, WebSocketMessage> { const WebSocketMessageStreamTransformer(); @@ -33,13 +37,18 @@ class WebSocketMessageStreamTransformer } } +@internal class WebSocketSubscriptionStreamTransformer<T> extends StreamTransformerBase<WebSocketMessage, GraphQLResponse<T>> { final GraphQLRequest<T> request; + final AmplifyLogger logger; final void Function()? onEstablished; const WebSocketSubscriptionStreamTransformer( - this.request, this.onEstablished); + this.request, + this.onEstablished, { + required this.logger, + }); @override Stream<GraphQLResponse<T>> bind(Stream<WebSocketMessage> stream) async* { @@ -52,15 +61,17 @@ class WebSocketSubscriptionStreamTransformer<T> final payload = event.payload as SubscriptionDataPayload; final errors = deserializeGraphQLResponseErrors(payload.toJson()); yield GraphQLResponseDecoder.instance.decode<T>( - request: request, - data: json.encode(payload.data), - errors: errors); + request: request, + data: json.encode(payload.data), + errors: errors, + ); break; case MessageType.error: final error = event.payload as WebSocketError; throw error; case MessageType.complete: + logger.info('Cancel succeeded for Operation: ${event.id}'); return; } } diff --git a/packages/api/amplify_api/test/ws/web_socket_connection_test.dart b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart index a18a63e582..8b62409dd2 100644 --- a/packages/api/amplify_api/test/ws/web_socket_connection_test.dart +++ b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart @@ -38,7 +38,9 @@ class MockWebSocketConnection extends WebSocketConnection { /// inspected for testing. final List<WebSocketMessage> sentMessages = []; - MockWebSocketConnection(super.config, super.authProviderRepo); + MockWebSocketConnection( + AWSApiConfig config, AmplifyAuthProviderRepository authProviderRepo) + : super(config, authProviderRepo, logger: AmplifyLogger()); WebSocketMessage? get lastSentMessage => sentMessages.lastOrNull; From ac5348a21d43245012c7d6300bb33b5ee3fc92e3 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Thu, 11 Aug 2022 12:50:27 -0700 Subject: [PATCH 17/33] throw error during connection --- .../src/decorators/web_socket_auth_utils.dart | 11 +++- .../src/graphql/ws/web_socket_connection.dart | 63 ++++++++++--------- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart b/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart index fb19c1ce2b..9e56f26305 100644 --- a/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart +++ b/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart @@ -21,6 +21,11 @@ import 'package:meta/meta.dart'; import '../graphql/ws/web_socket_types.dart'; import 'authorize_http_request.dart'; +// Constants for header values as noted in https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html. +const _acceptHeaderValue = 'application/json, text/javascript'; +const _contentEncodingHeaderValue = 'amz-1.0'; +const _contentTypeHeaderValue = 'application/json; charset=UTF-8'; + /// Generate a URI for the connection and all subscriptions. /// /// See https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html#handshake-details-to-establish-the-websocket-connection= @@ -90,9 +95,9 @@ Future<Map<String, String>> _generateAuthorizationHeaders( final canonicalHttpRequest = http.Request('POST', Uri.parse('${config.endpoint}$maybeConnect')); canonicalHttpRequest.headers.addAll({ - AWSHeaders.accept: 'application/json, text/javascript', - AWSHeaders.contentEncoding: 'amz-1.0', - AWSHeaders.contentType: 'application/json; charset=UTF-8', + AWSHeaders.accept: _acceptHeaderValue, + AWSHeaders.contentEncoding: _contentEncodingHeaderValue, + AWSHeaders.contentType: _contentTypeHeaderValue, }); canonicalHttpRequest.body = body; final authorizedHttpRequest = await authorizeHttpRequest( diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart index 9a1f2354d3..4de3d9272c 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart @@ -28,7 +28,7 @@ import 'web_socket_types.dart'; /// 1001, going away const _defaultCloseStatus = status.goingAway; -/// {@template amplify_api.web_socket_connection} +/// {@template amplify_api.ws.web_socket_connection} /// Manages connection with an AppSync backend and subscription routing. /// {@endtemplate} @internal @@ -45,9 +45,9 @@ class WebSocketConnection implements Closeable { // Manages all incoming messages from server. Primarily handles messages related // to the entire connection. E.g. connection_ack, connection_error, ka, error. // Other events (for single subscriptions) rebroadcast to _rebroadcastController. - late final WebSocketChannel _channel; - late final StreamSubscription<WebSocketMessage> _subscription; - late final RestartableTimer _timeoutTimer; + WebSocketChannel? _channel; + StreamSubscription<WebSocketMessage>? _subscription; + RestartableTimer? _timeoutTimer; // Re-broadcasts incoming messages for child streams (single GraphQL subscriptions). // start_ack, data, error @@ -55,17 +55,15 @@ class WebSocketConnection implements Closeable { StreamController<WebSocketMessage>.broadcast(); Stream<WebSocketMessage> get _messageStream => _rebroadcastController.stream; - // TODO: Add connection error variable to throw in `init`. + // Manage initial connection state. + var _initMemo = AsyncMemoizer<void>(); + Completer<void> _connectionReady = Completer<void>(); - // Futures to manage initial connection state. - final _initMemo = AsyncMemoizer<void>(); - final Completer<void> _connectionReady = Completer<void>(); - - /// Fires when the connection is ready to be listened to, i.e. - /// after the first `connection_ack` message. + /// Fires when the connection is ready to be listened to after the first + /// `connection_ack` message. Future<void> get ready => _connectionReady.future; - /// {@macro amplify_api.web_socket_connection} + /// {@macro amplify_api.ws.web_socket_connection} WebSocketConnection(this._config, this._authProviderRepo, {required AmplifyLogger logger}) : _logger = logger; @@ -76,7 +74,10 @@ class WebSocketConnection implements Closeable { Stream<dynamic> stream) { return stream .transform(const WebSocketMessageStreamTransformer()) - .listen(_onData); + .listen(_onData, onError: (Object e) { + _connectionError(ApiException('Connection failed.', + underlyingException: e.toString())); + }); } /// Connects WebSocket channel to _subscription stream but does not send connection @@ -87,18 +88,26 @@ class WebSocketConnection implements Closeable { connectionUri, protocols: webSocketProtocols, ); - _subscription = getStreamSubscription(_channel.stream); + _subscription = getStreamSubscription(_channel!.stream); + } + + void _connectionError(ApiException exception) { + _connectionReady.completeError(_connectionError); + _channel?.sink.close(); + // Reset connection init memo so it can be re-attempted. + _initMemo = AsyncMemoizer<void>(); + _connectionReady = Completer<void>(); } - /// Closes the WebSocket connection. + /// Closes the WebSocket connection and cleans up local variables. @override void close([int closeStatus = _defaultCloseStatus]) { final reason = closeStatus == _defaultCloseStatus ? 'client closed' : 'unknown'; - _subscription.cancel(); - _channel.sink.close(closeStatus, reason); + _subscription?.cancel(); + _channel?.sink.close(closeStatus, reason); _rebroadcastController.close(); - _timeoutTimer.cancel(); + _timeoutTimer?.cancel(); } /// Initializes the connection. @@ -113,7 +122,6 @@ class WebSocketConnection implements Closeable { await generateConnectionUri(_config, _authProviderRepo); await connect(connectionUri); - if (_connectionReady.isCompleted) return; send(WebSocketConnectionInitMessage()); return ready; @@ -125,9 +133,7 @@ class WebSocketConnection implements Closeable { GraphQLRequest<T> request, void Function()? onEstablished, ) { - if (!_connectionReady.isCompleted) { - init(); - } + init(); // no-op if already connected // Generate and send an authorized subscription registration message. final subscriptionId = uuid(); @@ -161,7 +167,7 @@ class WebSocketConnection implements Closeable { @visibleForTesting void send(WebSocketMessage message) { final msgJson = json.encode(message.toJson()); - _channel.sink.add(msgJson); + _channel?.sink.add(msgJson); } /// Times out the connection (usually if a keep alive has not been received in time). @@ -195,16 +201,11 @@ class WebSocketConnection implements Closeable { _logger.verbose('Connection established. Registered timer'); return; case MessageType.connectionError: - final wsError = message.payload as WebSocketError?; - _connectionReady.completeError( - wsError ?? - Exception( - 'An unknown error occurred while connecting to the WebSocket', - ), - ); + _connectionError(const ApiException( + 'Error occurred while connecting to the websocket')); return; case MessageType.keepAlive: - _timeoutTimer.reset(); + _timeoutTimer?.reset(); _logger.verbose('Reset timer'); return; case MessageType.error: From 2bc90247b48d92695e5fd9178136daeed8241fb3 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Mon, 15 Aug 2022 10:44:37 -0700 Subject: [PATCH 18/33] expand unit tests --- .../amplify_api/lib/src/api_plugin_impl.dart | 12 +- .../src/decorators/web_socket_auth_utils.dart | 18 ++- .../src/graphql/ws/web_socket_connection.dart | 40 ++++-- ...web_socket_message_stream_transformer.dart | 4 +- .../lib/src/graphql/ws/web_socket_types.dart | 6 +- .../amplify_api/test/dart_graphql_test.dart | 73 +++++++++- packages/api/amplify_api/test/util.dart | 115 +++++++++++++++ .../test/ws/web_socket_auth_utils_test.dart | 85 +++++++++++ .../test/ws/web_socket_connection_test.dart | 136 +++++------------- 9 files changed, 355 insertions(+), 134 deletions(-) create mode 100644 packages/api/amplify_api/test/ws/web_socket_auth_utils_test.dart diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index a6a574b5e2..66e0d0ca91 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -138,11 +138,13 @@ class AmplifyAPIDart extends AmplifyAPI { type: EndpointType.graphQL, apiName: apiName, ); - return _webSocketConnectionPool[endpoint.name] ??= - WebSocketConnection(endpoint.config, _authProviderRepo, - logger: _logger.createChild( - 'webSocketConnection${endpoint.name}', - )); + return _webSocketConnectionPool[endpoint.name] ??= WebSocketConnection( + endpoint.config, + _authProviderRepo, + logger: _logger.createChild( + 'webSocketConnection${endpoint.name}', + ), + ); } Uri _getGraphQLUri(String? apiName) { diff --git a/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart b/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart index 9e56f26305..f685c3821f 100644 --- a/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart +++ b/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart @@ -26,23 +26,29 @@ const _acceptHeaderValue = 'application/json, text/javascript'; const _contentEncodingHeaderValue = 'amz-1.0'; const _contentTypeHeaderValue = 'application/json; charset=UTF-8'; +// AppSync expects "{}" encoded in the URI as the payload during handshake. +const _emptyBody = '{}'; + /// Generate a URI for the connection and all subscriptions. /// /// See https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html#handshake-details-to-establish-the-websocket-connection= @internal Future<Uri> generateConnectionUri( AWSApiConfig config, AmplifyAuthProviderRepository authRepo) async { - const body = '{}'; - final authorizationHeaders = await _generateAuthorizationHeaders(config, - authRepo: authRepo, body: body); + final authorizationHeaders = await _generateAuthorizationHeaders( + config, + authRepo: authRepo, + body: _emptyBody, + ); final encodedAuthHeaders = base64.encode(json.encode(authorizationHeaders).codeUnits); final endpointUri = Uri.parse( - config.endpoint.replaceFirst('appsync-api', 'appsync-realtime-api')); + config.endpoint.replaceFirst('appsync-api', 'appsync-realtime-api'), + ); return Uri(scheme: 'wss', host: endpointUri.host, path: 'graphql') .replace(queryParameters: <String, String>{ 'header': encodedAuthHeaders, - 'payload': base64.encode(utf8.encode(body)) // always payload of '{}' + 'payload': base64.encode(utf8.encode(_emptyBody)), }); } @@ -91,7 +97,7 @@ Future<Map<String, String>> _generateAuthorizationHeaders( // // The canonical request URL is a little different depending on if connection_init // or start (subscription registration). - final maybeConnect = body != '{}' ? '' : '/connect'; + final maybeConnect = body != _emptyBody ? '' : '/connect'; final canonicalHttpRequest = http.Request('POST', Uri.parse('${config.endpoint}$maybeConnect')); canonicalHttpRequest.headers.addAll({ diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart index 4de3d9272c..683a10afb7 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart @@ -94,7 +94,11 @@ class WebSocketConnection implements Closeable { void _connectionError(ApiException exception) { _connectionReady.completeError(_connectionError); _channel?.sink.close(); - // Reset connection init memo so it can be re-attempted. + _resetConnectionInit(); + } + + // Reset connection init variables so it can be re-attempted. + void _resetConnectionInit() { _initMemo = AsyncMemoizer<void>(); _connectionReady = Completer<void>(); } @@ -102,12 +106,15 @@ class WebSocketConnection implements Closeable { /// Closes the WebSocket connection and cleans up local variables. @override void close([int closeStatus = _defaultCloseStatus]) { + _logger.verbose('Closing web socket connection.'); final reason = closeStatus == _defaultCloseStatus ? 'client closed' : 'unknown'; _subscription?.cancel(); + _channel?.sink.done.whenComplete(() => _channel = null); _channel?.sink.close(closeStatus, reason); _rebroadcastController.close(); _timeoutTimer?.cancel(); + _resetConnectionInit(); } /// Initializes the connection. @@ -133,16 +140,18 @@ class WebSocketConnection implements Closeable { GraphQLRequest<T> request, void Function()? onEstablished, ) { - init(); // no-op if already connected - - // Generate and send an authorized subscription registration message. final subscriptionId = uuid(); - generateSubscriptionRegistrationMessage( - _config, - id: subscriptionId, - authRepo: _authProviderRepo, - request: request, - ).then(send); + + // init is no-op if already connected + init().then((_) { + // Generate and send an authorized subscription registration message. + generateSubscriptionRegistrationMessage( + _config, + id: subscriptionId, + authRepo: _authProviderRepo, + request: request, + ).then(send); + }); // Filter incoming messages that have the subscription ID and return as new // stream with messages converted to GraphQLResponse<T>. @@ -167,7 +176,11 @@ class WebSocketConnection implements Closeable { @visibleForTesting void send(WebSocketMessage message) { final msgJson = json.encode(message.toJson()); - _channel?.sink.add(msgJson); + if (_channel == null) { + throw ApiException( + 'Web socket not connected. Cannot send message $message'); + } + _channel!.sink.add(msgJson); } /// Times out the connection (usually if a keep alive has not been received in time). @@ -185,7 +198,7 @@ class WebSocketConnection implements Closeable { /// Here, handle connection-wide messages and pass subscription events to /// `_rebroadcastController`. void _onData(WebSocketMessage message) { - _logger.verbose('websocket received message: $message'); + _logger.verbose('websocket received message: ${prettyPrintJson(message)}'); switch (message.messageType) { case MessageType.connectionAck: @@ -221,6 +234,7 @@ class WebSocketConnection implements Closeable { } // Re-broadcast other message types related to single subscriptions. - _rebroadcastController.add(message); + + if (!_rebroadcastController.isClosed) _rebroadcastController.add(message); } } diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart index 72d9c7160a..f9837c7272 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart @@ -31,8 +31,8 @@ class WebSocketMessageStreamTransformer @override Stream<WebSocketMessage> bind(Stream<dynamic> stream) { - return stream.cast<String>().map<Map>((str) { - return json.decode(str) as Map; + return stream.cast<String>().map<Map<String, Object?>>((str) { + return json.decode(str) as Map<String, Object?>; }).map(WebSocketMessage.fromJson); } } diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart index 0a754122bd..961a433fe4 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart @@ -124,7 +124,7 @@ class SubscriptionDataPayload extends WebSocketMessagePayload { final Map<String, dynamic>? data; final Map<String, dynamic>? errors; - SubscriptionDataPayload(this.data, this.errors); + const SubscriptionDataPayload(this.data, this.errors); static SubscriptionDataPayload fromJson(Map json) { final data = json['data'] as Map?; @@ -146,7 +146,7 @@ class SubscriptionDataPayload extends WebSocketMessagePayload { class WebSocketError extends WebSocketMessagePayload implements Exception { final List<Map> errors; - WebSocketError(this.errors); + const WebSocketError(this.errors); static WebSocketError fromJson(Map json) { final errors = json['errors'] as List?; @@ -172,7 +172,7 @@ class WebSocketMessage { this.payload, }) : id = id ?? uuid(); - WebSocketMessage._({ + const WebSocketMessage._({ this.id, required this.messageType, this.payload, diff --git a/packages/api/amplify_api/test/dart_graphql_test.dart b/packages/api/amplify_api/test/dart_graphql_test.dart index 4d9d8ec47f..f1d7919bef 100644 --- a/packages/api/amplify_api/test/dart_graphql_test.dart +++ b/packages/api/amplify_api/test/dart_graphql_test.dart @@ -12,10 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'dart:async'; import 'dart:convert'; import 'package:amplify_api/amplify_api.dart'; import 'package:amplify_api/src/api_plugin_impl.dart'; +import 'package:amplify_api/src/graphql/ws/web_socket_connection.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:amplify_test/test_models/ModelProvider.dart'; import 'package:collection/collection.dart'; @@ -24,6 +26,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'test_data/fake_amplify_configuration.dart'; +import 'util.dart'; final _deepEquals = const DeepCollectionEquality().equals; @@ -107,6 +110,10 @@ class MockAmplifyAPI extends AmplifyAPIDart { return http.Response( json.encode(_expectedQuerySuccessResponseBody), 200); }); + + @override + WebSocketConnection getWebSocketConnection({String? apiName}) => + MockWebSocketConnection(testApiKeyConfig, getTestAuthProviderRepo()); } void main() { @@ -127,7 +134,10 @@ void main() { } } } '''; - final req = GraphQLRequest(document: graphQLDocument, variables: {}); + final req = GraphQLRequest<String>( + document: graphQLDocument, + variables: {}, + ); final operation = Amplify.API.query(request: req); final res = await operation.value; @@ -147,8 +157,10 @@ void main() { } } '''; final graphQLVariables = {'name': 'Test Blog 1'}; - final req = GraphQLRequest( - document: graphQLDocument, variables: graphQLVariables); + final req = GraphQLRequest<String>( + document: graphQLDocument, + variables: graphQLVariables, + ); final operation = Amplify.API.mutate(request: req); final res = await operation.value; @@ -158,6 +170,33 @@ void main() { expect(res.data, equals(expected)); expect(res.errors, equals(null)); }); + + test('subscribe() should return a subscription stream', () async { + Completer<void> establishedCompleter = Completer(); + Completer<String> dataCompleter = Completer(); + const graphQLDocument = '''subscription MySubscription { + onCreateBlog { + id + name + createdAt + } + }'''; + final subscriptionRequest = + GraphQLRequest<String>(document: graphQLDocument); + final subscription = Amplify.API.subscribe( + subscriptionRequest, + onEstablished: () => establishedCompleter.complete(), + ); + + final streamSub = subscription.listen( + (event) => dataCompleter.complete(event.data), + ); + await expectLater(establishedCompleter.future, completes); + + final subscriptionData = await dataCompleter.future; + expect(subscriptionData, json.encode(mockSubscriptionData)); + streamSub.cancel(); + }); }); group('Model Helpers', () { const blogSelectionSet = @@ -184,12 +223,34 @@ void main() { expect(res.data?.id, _modelQueryId); expect(res.errors, equals(null)); }); + + test('subscribe() should decode model data', () async { + Completer<void> establishedCompleter = Completer(); + Completer<Post> dataCompleter = Completer(); + final subscriptionRequest = ModelSubscriptions.onCreate(Post.classType); + final subscription = Amplify.API.subscribe( + subscriptionRequest, + onEstablished: () => establishedCompleter.complete(), + ); + + final streamSub = subscription.listen( + (event) => dataCompleter.complete(event.data), + ); + await expectLater(establishedCompleter.future, completes); + + final subscriptionData = await dataCompleter.future; + expect(subscriptionData, isA<Post>()); + streamSub.cancel(); + }); }); group('Error Handling', () { test('response errors are decoded', () async { String graphQLDocument = ''' TestError '''; - final req = GraphQLRequest(document: graphQLDocument, variables: {}); + final req = GraphQLRequest<String>( + document: graphQLDocument, + variables: {}, + ); final operation = Amplify.API.query(request: req); final res = await operation.value; @@ -209,7 +270,7 @@ void main() { }); test('canceled query request should never resolve', () async { - final req = GraphQLRequest(document: '', variables: {}); + final req = GraphQLRequest<String>(document: '', variables: {}); final operation = Amplify.API.query(request: req); operation.cancel(); operation.then((p0) => fail('Request should have been cancelled.')); @@ -218,7 +279,7 @@ void main() { }); test('canceled mutation request should never resolve', () async { - final req = GraphQLRequest(document: '', variables: {}); + final req = GraphQLRequest<String>(document: '', variables: {}); final operation = Amplify.API.mutate(request: req); operation.cancel(); operation.then((p0) => fail('Request should have been cancelled.')); diff --git a/packages/api/amplify_api/test/util.dart b/packages/api/amplify_api/test/util.dart index cd06f8c13c..7da7c56c1b 100644 --- a/packages/api/amplify_api/test/util.dart +++ b/packages/api/amplify_api/test/util.dart @@ -12,8 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'dart:async'; +import 'dart:convert'; + +import 'package:amplify_api/src/graphql/app_sync_api_key_auth_provider.dart'; +import 'package:amplify_api/src/graphql/ws/web_socket_connection.dart'; +import 'package:amplify_api/src/graphql/ws/web_socket_types.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:aws_signature_v4/aws_signature_v4.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -60,3 +67,111 @@ void validateSignedRequest(http.BaseRequest request) { contains('aws-sigv4'), ); } + +const testApiKeyConfig = AWSApiConfig( + endpointType: EndpointType.graphQL, + endpoint: 'https://abc123.appsync-api.us-east-1.amazonaws.com/graphql', + region: 'us-east-1', + authorizationType: APIAuthorizationType.apiKey, + apiKey: 'abc-123', +); + +const expectedApiKeyWebSocketConnectionUrl = + 'wss://abc123.appsync-realtime-api.us-east-1.amazonaws.com/graphql?header=eyJDb250ZW50LVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uOyBjaGFyc2V0PVVURi04IiwiWC1BcGktS2V5IjoiYWJjLTEyMyIsIkFjY2VwdCI6ImFwcGxpY2F0aW9uL2pzb24sIHRleHQvamF2YXNjcmlwdCIsIkNvbnRlbnQtRW5jb2RpbmciOiJhbXotMS4wIiwiSG9zdCI6ImFiYzEyMy5hcHBzeW5jLWFwaS51cy1lYXN0LTEuYW1hem9uYXdzLmNvbSJ9&payload=e30%3D'; + +AmplifyAuthProviderRepository getTestAuthProviderRepo() { + final testAuthProviderRepo = AmplifyAuthProviderRepository(); + testAuthProviderRepo.registerAuthProvider( + APIAuthorizationType.apiKey.authProviderToken, + AppSyncApiKeyAuthProvider(), + ); + + return testAuthProviderRepo; +} + +const mockSubscriptionData = { + 'onCreatePost': { + 'id': '49d54440-cb80-4f20-964b-91c142761e82', + 'title': + 'Integration Test post - subscription create aa779f0d-0c92-4677-af32-e43f71b3eb55', + 'rating': 0, + 'created': null, + 'createdAt': '2022-08-15T18:22:15.410Z', + 'updatedAt': '2022-08-15T18:22:15.410Z', + 'blog': { + 'id': '164bd1f1-544c-40cb-a656-a7563b046e71', + 'name': 'Integration Test Blog with a post - create', + 'createdAt': '2022-08-15T18:22:15.164Z', + 'file': null, + 'files': null, + 'updatedAt': '2022-08-15T18:22:15.164Z' + } + } +}; + +/// Extension of [WebSocketConnection] that stores messages internally instead +/// of sending them. +class MockWebSocketConnection extends WebSocketConnection { + /// Instead of actually connecting, just set the URI here so it can be inspected + /// for testing. + Uri? connectedUri; + + /// Instead of sending messages, they are pushed to end of list so they can be + /// inspected for testing. + final List<WebSocketMessage> sentMessages = []; + + MockWebSocketConnection( + AWSApiConfig config, AmplifyAuthProviderRepository authProviderRepo) + : super(config, authProviderRepo, logger: AmplifyLogger()); + + WebSocketMessage? get lastSentMessage => sentMessages.lastOrNull; + + final messageStream = StreamController<dynamic>(); + + @override + Future<void> connect(Uri connectionUri) async { + connectedUri = connectionUri; + + // mock some message responses (acks and mock data) from server + final broadcast = messageStream.stream.asBroadcastStream(); + broadcast.listen((event) { + final eventJson = json.decode(event as String); + final messageFromEvent = WebSocketMessage.fromJson(eventJson as Map); + + // connection_init, respond with connection_ack + final mockResponseMessages = <WebSocketMessage>[]; + if (messageFromEvent.messageType == MessageType.connectionInit) { + mockResponseMessages.add(WebSocketMessage( + messageType: MessageType.connectionAck, + payload: const ConnectionAckMessagePayload(10000), + )); + // start, respond with start_ack and mock data + } else if (messageFromEvent.messageType == MessageType.start) { + mockResponseMessages.add(WebSocketMessage( + messageType: MessageType.startAck, + id: messageFromEvent.id, + )); + mockResponseMessages.add(WebSocketMessage( + messageType: MessageType.data, + id: messageFromEvent.id, + payload: const SubscriptionDataPayload(mockSubscriptionData, null), + )); + } + + for (var mockMessage in mockResponseMessages) { + messageStream.add(json.encode(mockMessage)); + } + }); + + // ensures connected to _onDone events in parent class + getStreamSubscription(broadcast); + } + + /// Pushes message in sentMessages and adds to stream (to support mocking result). + @override + void send(WebSocketMessage message) { + sentMessages.add(message); + final messageStr = json.encode(message.toJson()); + messageStream.add(messageStr); + } +} diff --git a/packages/api/amplify_api/test/ws/web_socket_auth_utils_test.dart b/packages/api/amplify_api/test/ws/web_socket_auth_utils_test.dart new file mode 100644 index 0000000000..19cb61a647 --- /dev/null +++ b/packages/api/amplify_api/test/ws/web_socket_auth_utils_test.dart @@ -0,0 +1,85 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'package:amplify_api/src/decorators/web_socket_auth_utils.dart'; +import 'package:amplify_api/src/graphql/app_sync_api_key_auth_provider.dart'; +import 'package:amplify_api/src/graphql/ws/web_socket_types.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../util.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final authProviderRepo = AmplifyAuthProviderRepository(); + authProviderRepo.registerAuthProvider( + APIAuthorizationType.apiKey.authProviderToken, + AppSyncApiKeyAuthProvider()); + + const graphQLDocument = '''subscription MySubscription { + onCreateBlog { + id + name + createdAt + } + }'''; + final subscriptionRequest = GraphQLRequest<String>(document: graphQLDocument); + + void _assertBasicSubscriptionPayloadHeaders( + SubscriptionRegistrationPayload payload) { + expect( + payload.authorizationHeaders[AWSHeaders.contentType], + 'application/json; charset=UTF-8', + ); + expect( + payload.authorizationHeaders[AWSHeaders.accept], + 'application/json, text/javascript', + ); + expect( + payload.authorizationHeaders[AWSHeaders.host], + 'abc123.appsync-api.us-east-1.amazonaws.com', + ); + } + + group('generateConnectionUri', () { + test('should generate authorized connection URI', () async { + final actualConnectionUri = + await generateConnectionUri(testApiKeyConfig, authProviderRepo); + expect( + actualConnectionUri.toString(), + expectedApiKeyWebSocketConnectionUrl, + ); + }); + }); + + group('generateSubscriptionRegistrationMessage', () { + test('should generate an authorized message', () async { + final authorizedMessage = await generateSubscriptionRegistrationMessage( + testApiKeyConfig, + id: 'abc123', + authRepo: authProviderRepo, + request: subscriptionRequest, + ); + final payload = + authorizedMessage.payload as SubscriptionRegistrationPayload; + + _assertBasicSubscriptionPayloadHeaders(payload); + expect( + payload.authorizationHeaders[xApiKey], + testApiKeyConfig.apiKey, + ); + }); + }); +} diff --git a/packages/api/amplify_api/test/ws/web_socket_connection_test.dart b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart index 8b62409dd2..81e0d87d6e 100644 --- a/packages/api/amplify_api/test/ws/web_socket_connection_test.dart +++ b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart @@ -15,101 +15,16 @@ import 'dart:async'; import 'dart:convert'; -import 'package:amplify_api/src/api_plugin_impl.dart'; import 'package:amplify_api/src/graphql/app_sync_api_key_auth_provider.dart'; import 'package:amplify_api/src/graphql/ws/web_socket_types.dart'; import 'package:amplify_core/amplify_core.dart'; -import 'package:collection/collection.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; import '../util.dart'; -import 'package:amplify_api/src/graphql/ws/web_socket_connection.dart'; - -/// Extension of [WebSocketConnection] that stores messages internally instead -/// of sending them. -class MockWebSocketConnection extends WebSocketConnection { - /// Instead of actually connecting, just set the URI here so it can be inspected - /// for testing. - Uri? connectedUri; - - /// Instead of sending messages, they are pushed to end of list so they can be - /// inspected for testing. - final List<WebSocketMessage> sentMessages = []; - - MockWebSocketConnection( - AWSApiConfig config, AmplifyAuthProviderRepository authProviderRepo) - : super(config, authProviderRepo, logger: AmplifyLogger()); - - WebSocketMessage? get lastSentMessage => sentMessages.lastOrNull; - - final messageStream = StreamController<dynamic>(); - - @override - Future<void> connect(Uri connectionUri) async { - connectedUri = connectionUri; - - // mock some message responses (acks) from server - final broadcast = messageStream.stream.asBroadcastStream(); - broadcast.listen((event) { - final eventJson = json.decode(event as String); - final messageFromEvent = WebSocketMessage.fromJson(eventJson as Map); - - // connection_init, respond with connection_ack - WebSocketMessage? mockAckMsg; - if (messageFromEvent.messageType == MessageType.connectionInit) { - mockAckMsg = WebSocketMessage( - messageType: MessageType.connectionAck, - payload: const ConnectionAckMessagePayload(10000), - ); - // start, respond with start_ack - } else if (messageFromEvent.messageType == MessageType.start) { - mockAckMsg = WebSocketMessage( - messageType: MessageType.startAck, - id: messageFromEvent.id, - ); - } - if (mockAckMsg != null) { - final messageStr = json.encode(mockAckMsg); - messageStream.add(messageStr); - } - }); - - // ensures connected to _onDone events in parent class - getStreamSubscription(broadcast); - } - - /// Pushes message in sentMessages and adds to stream (to support mocking result). - @override - void send(WebSocketMessage message) { - sentMessages.add(message); - final messageStr = json.encode(message.toJson()); - messageStream.add(messageStr); - } -} - void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final authProviderRepo = AmplifyAuthProviderRepository(); - authProviderRepo.registerAuthProvider( - APIAuthorizationType.apiKey.authProviderToken, - AppSyncApiKeyAuthProvider()); - - const endpointType = EndpointType.graphQL; - const endpoint = 'https://abc123.appsync-api.us-east-1.amazonaws.com/graphql'; - const region = 'us-east-1'; - const authorizationType = APIAuthorizationType.apiKey; - const apiKey = 'abc-123'; - - const config = AWSApiConfig( - endpointType: endpointType, - endpoint: endpoint, - region: region, - authorizationType: authorizationType, - apiKey: apiKey); - late MockWebSocketConnection connection; const graphQLDocument = '''subscription MySubscription { @@ -122,7 +37,10 @@ void main() { final subscriptionRequest = GraphQLRequest<String>(document: graphQLDocument); setUp(() { - connection = MockWebSocketConnection(config, authProviderRepo); + connection = MockWebSocketConnection( + testApiKeyConfig, + getTestAuthProviderRepo(), + ); }); group('WebSocketConnection', () { @@ -131,9 +49,10 @@ void main() { () async { await connection.init(); expectLater(connection.ready, completes); - const expectedConnectionUri = - 'wss://abc123.appsync-realtime-api.us-east-1.amazonaws.com/graphql?header=eyJDb250ZW50LVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uOyBjaGFyc2V0PVVURi04IiwiWC1BcGktS2V5IjoiYWJjLTEyMyIsIkFjY2VwdCI6ImFwcGxpY2F0aW9uL2pzb24sIHRleHQvamF2YXNjcmlwdCIsIkNvbnRlbnQtRW5jb2RpbmciOiJhbXotMS4wIiwiSG9zdCI6ImFiYzEyMy5hcHBzeW5jLWFwaS51cy1lYXN0LTEuYW1hem9uYXdzLmNvbSJ9&payload=e30%3D'; - expect(connection.connectedUri.toString(), expectedConnectionUri); + expect( + connection.connectedUri.toString(), + expectedApiKeyWebSocketConnectionUrl, + ); expect( connection.lastSentMessage?.messageType, MessageType.connectionInit); }); @@ -165,17 +84,36 @@ void main() { final payloadJson = lastMessage?.payload?.toJson(); final apiKeyFromPayload = payloadJson?['extensions']['authorization'][xApiKey]; - expect(apiKeyFromPayload, apiKey); + expect(apiKeyFromPayload, testApiKeyConfig.apiKey); }); - // test('subscribe() should return a subscription stream', () async { - // connection.init(); - // await connection.ready; - // Completer<void> establishedCompleter = Completer(); - // final subscription = connection.subscribe(subscriptionRequest, () { - // establishedCompleter.complete(); - // }).listen((event) {}); - // await establishedCompleter.future; - // }); + test('subscribe() should return a subscription stream', () async { + Completer<void> establishedCompleter = Completer(); + Completer<String> dataCompleter = Completer(); + final subscription = connection.subscribe( + subscriptionRequest, + () => establishedCompleter.complete(), + ); + + final streamSub = subscription.listen( + (event) => dataCompleter.complete(event.data), + ); + await expectLater(establishedCompleter.future, completes); + + final subscriptionData = await dataCompleter.future; + expect(subscriptionData, json.encode(mockSubscriptionData)); + streamSub.cancel(); + }); + + test('cancel() should send a stop message', () async { + Completer<void> establishedCompleter = Completer(); + final subscription = connection.subscribe(subscriptionRequest, () { + establishedCompleter.complete(); + }); + final streamSub = subscription.listen((event) {}); + await establishedCompleter.future; + streamSub.cancel(); + expect(connection.lastSentMessage?.messageType, MessageType.stop); + }); }); } From bdf706fffbe1fe8c96ecbe3f7b36397265ae7cc7 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Wed, 15 Jun 2022 11:35:34 -0800 Subject: [PATCH 19/33] chore!(api): migrate API category type definitions (#1640) --- .../src/category/amplify_api_category.dart | 134 +++++---- .../lib/src/category/amplify_categories.dart | 1 + .../plugin/amplify_api_plugin_interface.dart | 74 +++-- .../lib/src/types/api/api_types.dart | 2 +- .../types/api/exceptions/api_exception.dart | 9 - .../types/api/graphql/graphql_operation.dart | 15 +- .../lib/src/types/api/rest/http_payload.dart | 82 ++++++ .../src/types/api/rest/rest_exception.dart | 14 +- .../src/types/api/rest/rest_operation.dart | 20 +- .../lib/src/types/api/rest/rest_response.dart | 59 ---- packages/api/amplify_api/.gitignore | 40 ++- .../example/lib/graphql_api_view.dart | 3 +- .../api/amplify_api/example/lib/main.dart | 2 +- .../example/lib/rest_api_view.dart | 65 ++--- packages/api/amplify_api/example/pubspec.yaml | 1 + .../lib/src/method_channel_api.dart | 229 +++++++++++---- packages/api/amplify_api/pubspec.yaml | 3 +- .../test/amplify_rest_api_methods_test.dart | 270 ++++++++---------- .../example/lib/main.dart | 13 +- 19 files changed, 611 insertions(+), 425 deletions(-) create mode 100644 packages/amplify_core/lib/src/types/api/rest/http_payload.dart delete mode 100644 packages/amplify_core/lib/src/types/api/rest/rest_response.dart diff --git a/packages/amplify_core/lib/src/category/amplify_api_category.dart b/packages/amplify_core/lib/src/category/amplify_api_category.dart index 99406d31fc..7d9692a725 100644 --- a/packages/amplify_core/lib/src/category/amplify_api_category.dart +++ b/packages/amplify_core/lib/src/category/amplify_api_category.dart @@ -1,5 +1,5 @@ /* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. @@ -21,17 +21,13 @@ class APICategory extends AmplifyCategory<APIPluginInterface> { Category get category => Category.api; // ====== GraphQL ======= - GraphQLOperation<T> query<T>({required GraphQLRequest<T> request}) { - return plugins.length == 1 - ? plugins[0].query(request: request) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<GraphQLResponse<T>> query<T>( + {required GraphQLRequest<T> request}) => + defaultPlugin.query(request: request); - GraphQLOperation<T> mutate<T>({required GraphQLRequest<T> request}) { - return plugins.length == 1 - ? plugins[0].mutate(request: request) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<GraphQLResponse<T>> mutate<T>( + {required GraphQLRequest<T> request}) => + defaultPlugin.mutate(request: request); /// Subscribes to the given [request] and returns the stream of response events. /// An optional [onEstablished] callback can be used to be alerted when the @@ -42,52 +38,88 @@ class APICategory extends AmplifyCategory<APIPluginInterface> { Stream<GraphQLResponse<T>> subscribe<T>( GraphQLRequest<T> request, { void Function()? onEstablished, - }) { - return plugins.length == 1 - ? plugins[0].subscribe(request, onEstablished: onEstablished) - : throw _pluginNotAddedException('Api'); - } + }) => + defaultPlugin.subscribe(request, onEstablished: onEstablished); // ====== RestAPI ====== - void cancelRequest(String cancelToken) { - return plugins.length == 1 - ? plugins[0].cancelRequest(cancelToken) - : throw _pluginNotAddedException('Api'); - } - RestOperation get({required RestOptions restOptions}) { - return plugins.length == 1 - ? plugins[0].get(restOptions: restOptions) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<AWSStreamedHttpResponse> delete( + String path, { + Map<String, String>? headers, + HttpPayload? body, + Map<String, String>? queryParameters, + String? apiName, + }) => + defaultPlugin.delete( + path, + headers: headers, + body: body, + apiName: apiName, + ); - RestOperation put({required RestOptions restOptions}) { - return plugins.length == 1 - ? plugins[0].put(restOptions: restOptions) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<AWSStreamedHttpResponse> get( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) => + defaultPlugin.get( + path, + headers: headers, + apiName: apiName, + ); - RestOperation post({required RestOptions restOptions}) { - return plugins.length == 1 - ? plugins[0].post(restOptions: restOptions) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<AWSStreamedHttpResponse> head( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) => + defaultPlugin.head( + path, + headers: headers, + apiName: apiName, + ); - RestOperation delete({required RestOptions restOptions}) { - return plugins.length == 1 - ? plugins[0].delete(restOptions: restOptions) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<AWSStreamedHttpResponse> patch( + String path, { + Map<String, String>? headers, + HttpPayload? body, + Map<String, String>? queryParameters, + String? apiName, + }) => + defaultPlugin.patch( + path, + headers: headers, + body: body, + apiName: apiName, + ); - RestOperation head({required RestOptions restOptions}) { - return plugins.length == 1 - ? plugins[0].head(restOptions: restOptions) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<AWSStreamedHttpResponse> post( + String path, { + Map<String, String>? headers, + HttpPayload? body, + Map<String, String>? queryParameters, + String? apiName, + }) => + defaultPlugin.post( + path, + headers: headers, + body: body, + apiName: apiName, + ); - RestOperation patch({required RestOptions restOptions}) { - return plugins.length == 1 - ? plugins[0].patch(restOptions: restOptions) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation<AWSStreamedHttpResponse> put( + String path, { + Map<String, String>? headers, + HttpPayload? body, + Map<String, String>? queryParameters, + String? apiName, + }) => + defaultPlugin.put( + path, + headers: headers, + body: body, + apiName: apiName, + ); } diff --git a/packages/amplify_core/lib/src/category/amplify_categories.dart b/packages/amplify_core/lib/src/category/amplify_categories.dart index 969ea3ebc7..4c014d05dc 100644 --- a/packages/amplify_core/lib/src/category/amplify_categories.dart +++ b/packages/amplify_core/lib/src/category/amplify_categories.dart @@ -18,6 +18,7 @@ library amplify_interface; import 'dart:async'; import 'package:amplify_core/amplify_core.dart'; +import 'package:async/async.dart'; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; diff --git a/packages/amplify_core/lib/src/plugin/amplify_api_plugin_interface.dart b/packages/amplify_core/lib/src/plugin/amplify_api_plugin_interface.dart index d318db6e13..5169acb091 100644 --- a/packages/amplify_core/lib/src/plugin/amplify_api_plugin_interface.dart +++ b/packages/amplify_core/lib/src/plugin/amplify_api_plugin_interface.dart @@ -1,5 +1,5 @@ /* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. @@ -14,6 +14,7 @@ */ import 'package:amplify_core/amplify_core.dart'; +import 'package:async/async.dart'; import 'package:meta/meta.dart'; abstract class APIPluginInterface extends AmplifyPluginInterface { @@ -25,11 +26,13 @@ abstract class APIPluginInterface extends AmplifyPluginInterface { ModelProviderInterface? get modelProvider => throw UnimplementedError(); // ====== GraphQL ======= - GraphQLOperation<T> query<T>({required GraphQLRequest<T> request}) { + CancelableOperation<GraphQLResponse<T>> query<T>( + {required GraphQLRequest<T> request}) { throw UnimplementedError('query() has not been implemented.'); } - GraphQLOperation<T> mutate<T>({required GraphQLRequest<T> request}) { + CancelableOperation<GraphQLResponse<T>> mutate<T>( + {required GraphQLRequest<T> request}) { throw UnimplementedError('mutate() has not been implemented.'); } @@ -50,31 +53,64 @@ abstract class APIPluginInterface extends AmplifyPluginInterface { void registerAuthProvider(APIAuthProvider authProvider); // ====== RestAPI ====== - void cancelRequest(String cancelToken) { - throw UnimplementedError('cancelRequest has not been implemented.'); - } - - RestOperation get({required RestOptions restOptions}) { - throw UnimplementedError('get has not been implemented.'); + CancelableOperation<AWSStreamedHttpResponse> delete( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + throw UnimplementedError('delete() has not been implemented'); } - RestOperation put({required RestOptions restOptions}) { - throw UnimplementedError('put has not been implemented.'); + /// Uses Amplify configuration to authorize request to [path] and returns + /// [CancelableOperation] which resolves to standard HTTP + /// [Response](https://pub.dev/documentation/http/latest/http/Response-class.html). + CancelableOperation<AWSStreamedHttpResponse> get( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + throw UnimplementedError('get() has not been implemented'); } - RestOperation post({required RestOptions restOptions}) { - throw UnimplementedError('post has not been implemented.'); + CancelableOperation<AWSStreamedHttpResponse> head( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + throw UnimplementedError('head() has not been implemented'); } - RestOperation delete({required RestOptions restOptions}) { - throw UnimplementedError('delete has not been implemented.'); + CancelableOperation<AWSStreamedHttpResponse> patch( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + throw UnimplementedError('patch() has not been implemented'); } - RestOperation head({required RestOptions restOptions}) { - throw UnimplementedError('head has not been implemented.'); + CancelableOperation<AWSStreamedHttpResponse> post( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + throw UnimplementedError('post() has not been implemented'); } - RestOperation patch({required RestOptions restOptions}) { - throw UnimplementedError('patch has not been implemented.'); + CancelableOperation<AWSStreamedHttpResponse> put( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + throw UnimplementedError('put() has not been implemented'); } } diff --git a/packages/amplify_core/lib/src/types/api/api_types.dart b/packages/amplify_core/lib/src/types/api/api_types.dart index 3e69a1dc4b..299fd03412 100644 --- a/packages/amplify_core/lib/src/types/api/api_types.dart +++ b/packages/amplify_core/lib/src/types/api/api_types.dart @@ -27,10 +27,10 @@ export 'graphql/graphql_response.dart'; export 'graphql/graphql_response_error.dart'; export 'graphql/graphql_subscription_operation.dart'; +export 'rest/http_payload.dart'; export 'rest/rest_exception.dart'; export 'rest/rest_operation.dart'; export 'rest/rest_options.dart'; -export 'rest/rest_response.dart'; export 'types/pagination/paginated_model_type.dart'; export 'types/pagination/paginated_result.dart'; diff --git a/packages/amplify_core/lib/src/types/api/exceptions/api_exception.dart b/packages/amplify_core/lib/src/types/api/exceptions/api_exception.dart index 9f9d833110..2ec1bf37ac 100644 --- a/packages/amplify_core/lib/src/types/api/exceptions/api_exception.dart +++ b/packages/amplify_core/lib/src/types/api/exceptions/api_exception.dart @@ -19,18 +19,11 @@ import 'package:amplify_core/amplify_core.dart'; /// Exception thrown from the API Category. /// {@endtemplate} class ApiException extends AmplifyException { - /// HTTP status of response, only available if error - @Deprecated( - 'Use RestException instead to retrieve the HTTP response. Existing uses of ' - 'ApiException for handling REST errors can be safely replaced with RestException') - final int? httpStatusCode; - /// {@macro api_exception} const ApiException( String message, { String? recoverySuggestion, String? underlyingException, - this.httpStatusCode, }) : super( message, recoverySuggestion: recoverySuggestion, @@ -40,7 +33,6 @@ class ApiException extends AmplifyException { /// Constructor for down casting an AmplifyException to this exception ApiException._private( AmplifyException exception, - this.httpStatusCode, ) : super( exception.message, recoverySuggestion: exception.recoverySuggestion, @@ -57,7 +49,6 @@ class ApiException extends AmplifyException { } return ApiException._private( AmplifyException.fromMap(serializedException), - statusCode, ); } } diff --git a/packages/amplify_core/lib/src/types/api/graphql/graphql_operation.dart b/packages/amplify_core/lib/src/types/api/graphql/graphql_operation.dart index 94035a8997..b9f72dbd37 100644 --- a/packages/amplify_core/lib/src/types/api/graphql/graphql_operation.dart +++ b/packages/amplify_core/lib/src/types/api/graphql/graphql_operation.dart @@ -1,5 +1,5 @@ /* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. @@ -13,11 +13,14 @@ * permissions and limitations under the License. */ -import 'package:amplify_core/amplify_core.dart'; +import 'package:async/async.dart'; -class GraphQLOperation<T> { - final Function cancel; - final Future<GraphQLResponse<T>> response; +import 'graphql_response.dart'; - const GraphQLOperation({required this.response, required this.cancel}); +/// Allows callers to synchronously get the unstreamed response with decoded body. +extension GraphQLOperation<T> on CancelableOperation<GraphQLResponse<T>> { + @Deprecated('use .value instead') + Future<GraphQLResponse<T>> get response { + return value; + } } diff --git a/packages/amplify_core/lib/src/types/api/rest/http_payload.dart b/packages/amplify_core/lib/src/types/api/rest/http_payload.dart new file mode 100644 index 0000000000..eb657d7543 --- /dev/null +++ b/packages/amplify_core/lib/src/types/api/rest/http_payload.dart @@ -0,0 +1,82 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ +import 'dart:async'; +import 'dart:convert'; + +import 'package:async/async.dart'; + +/// {@template amplify_core.http_payload} +/// An HTTP request's payload. +/// {@endtemplate} +class HttpPayload extends StreamView<List<int>> { + String contentType = 'text/plain'; + + /// {@macro amplify_core.http_payload} + factory HttpPayload([Object? body]) { + if (body == null) { + return HttpPayload.empty(); + } + if (body is String) { + return HttpPayload.string(body); + } + if (body is List<int>) { + return HttpPayload.bytes(body); + } + if (body is Stream<List<int>>) { + return HttpPayload.streaming(body); + } + if (body is Map<String, String>) { + return HttpPayload.formFields(body); + } + throw ArgumentError('Invalid HTTP payload type: ${body.runtimeType}'); + } + + /// An empty HTTP body. + HttpPayload.empty() : super(const Stream.empty()); + + /// A UTF-8 encoded HTTP body. + HttpPayload.string(String body, {Encoding encoding = utf8}) + : super(LazyStream(() => Stream.value(encoding.encode(body)))); + + /// A byte buffer HTTP body. + HttpPayload.bytes(List<int> body) : super(Stream.value(body)); + + /// A form-encoded body of `key=value` pairs. + HttpPayload.formFields(Map<String, String> body, {Encoding encoding = utf8}) + : contentType = 'application/x-www-form-urlencoded', + super(LazyStream(() => Stream.value( + encoding.encode(_mapToQuery(body, encoding: encoding))))); + + /// Encodes body as a JSON string and sets Content-Type to 'application/json' + HttpPayload.json(Object body, {Encoding encoding = utf8}) + : contentType = 'application/json', + super( + LazyStream(() => Stream.value(encoding.encode(json.encode(body))))); + + /// A streaming HTTP body. + HttpPayload.streaming(Stream<List<int>> body) : super(body); +} + +/// Converts a [Map] from parameter names to values to a URL query string. +/// +/// _mapToQuery({"foo": "bar", "baz": "bang"}); +/// //=> "foo=bar&baz=bang" +/// +/// Similar util at https://github.com/dart-lang/http/blob/06649afbb5847dbb0293816ba8348766b116e419/pkgs/http/lib/src/utils.dart#L15 +String _mapToQuery(Map<String, String> map, {required Encoding encoding}) => map + .entries + .map((e) => + '${Uri.encodeQueryComponent(e.key, encoding: encoding)}=${Uri.encodeQueryComponent(e.value, encoding: encoding)}') + .join('&'); diff --git a/packages/amplify_core/lib/src/types/api/rest/rest_exception.dart b/packages/amplify_core/lib/src/types/api/rest/rest_exception.dart index fe6a6a8ee5..1f6dc18c2e 100644 --- a/packages/amplify_core/lib/src/types/api/rest/rest_exception.dart +++ b/packages/amplify_core/lib/src/types/api/rest/rest_exception.dart @@ -19,16 +19,8 @@ import 'package:amplify_core/amplify_core.dart'; /// An HTTP error encountered during a REST API call, i.e. for calls returning /// non-2xx status codes. /// {@endtemplate} -class RestException extends ApiException { - /// The HTTP response from the server. - final RestResponse response; - +@Deprecated('BREAKING CHANGE: No longer thrown for non-200 responses.') +abstract class RestException extends ApiException { /// {@macro rest_exception} - RestException(this.response) - : super(response.body, httpStatusCode: response.statusCode); - - @override - String toString() { - return 'RestException{response=$response}'; - } + const RestException() : super('REST exception.'); } diff --git a/packages/amplify_core/lib/src/types/api/rest/rest_operation.dart b/packages/amplify_core/lib/src/types/api/rest/rest_operation.dart index eb84a0ea42..a24ad39ad2 100644 --- a/packages/amplify_core/lib/src/types/api/rest/rest_operation.dart +++ b/packages/amplify_core/lib/src/types/api/rest/rest_operation.dart @@ -1,5 +1,5 @@ /* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. @@ -13,11 +13,17 @@ * permissions and limitations under the License. */ -import 'rest_response.dart'; +import 'package:async/async.dart'; +import 'package:aws_common/aws_common.dart'; -class RestOperation { - final Function cancel; - final Future<RestResponse> response; - - const RestOperation({required this.response, required this.cancel}); +/// Allows callers to synchronously get unstreamed response with the decoded body. +extension RestOperation on CancelableOperation<AWSStreamedHttpResponse> { + Future<AWSHttpResponse> get response async { + final value = await this.value; + return AWSHttpResponse( + body: await value.bodyBytes, + statusCode: value.statusCode, + headers: value.headers, + ); + } } diff --git a/packages/amplify_core/lib/src/types/api/rest/rest_response.dart b/packages/amplify_core/lib/src/types/api/rest/rest_response.dart deleted file mode 100644 index f93a2079e4..0000000000 --- a/packages/amplify_core/lib/src/types/api/rest/rest_response.dart +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. 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. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file 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. - */ - -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:amplify_core/amplify_core.dart'; -import 'package:meta/meta.dart'; - -/// {@template rest_response} -/// An HTTP response from a REST API call. -/// {@endtemplate} -@immutable -class RestResponse with AWSEquatable<RestResponse> { - /// The response status code. - final int statusCode; - - /// The response headers. - /// - /// Will be `null` if unavailable from the platform. - final Map<String, String>? headers; - - /// The response body bytes. - final Uint8List data; - - /// The decoded body (using UTF-8). - /// - /// For custom processing, use [data] to get the raw body bytes. - late final String body; - - /// {@macro rest_response} - RestResponse({ - required Uint8List? data, - required this.headers, - required this.statusCode, - }) : data = data ?? Uint8List(0) { - body = utf8.decode(this.data, allowMalformed: true); - } - - @override - List<Object?> get props => [statusCode, headers, data]; - - @override - String toString() { - return 'RestResponse{statusCode=$statusCode, headers=$headers, body=$body}'; - } -} diff --git a/packages/api/amplify_api/.gitignore b/packages/api/amplify_api/.gitignore index e9dc58d3d6..6bb69a50e0 100644 --- a/packages/api/amplify_api/.gitignore +++ b/packages/api/amplify_api/.gitignore @@ -1,7 +1,43 @@ +# See https://dart.dev/guides/libraries/private-files + +# Miscellaneous +*.class +*.log +*.pyc +*.swp .DS_Store -.dart_tool/ +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies .packages +.pub-cache/ .pub/ - build/ + +# Code coverage +coverage/ + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/packages/api/amplify_api/example/lib/graphql_api_view.dart b/packages/api/amplify_api/example/lib/graphql_api_view.dart index 53a218efcd..6644dad380 100644 --- a/packages/api/amplify_api/example/lib/graphql_api_view.dart +++ b/packages/api/amplify_api/example/lib/graphql_api_view.dart @@ -14,6 +14,7 @@ */ import 'package:amplify_flutter/amplify_flutter.dart'; +import 'package:async/async.dart'; import 'package:flutter/material.dart'; class GraphQLApiView extends StatefulWidget { @@ -29,7 +30,7 @@ class GraphQLApiView extends StatefulWidget { class _GraphQLApiViewState extends State<GraphQLApiView> { String _result = ''; void Function()? _unsubscribe; - late GraphQLOperation _lastOperation; + late CancelableOperation _lastOperation; Future<void> subscribe() async { String graphQLDocument = '''subscription MySubscription { diff --git a/packages/api/amplify_api/example/lib/main.dart b/packages/api/amplify_api/example/lib/main.dart index 5c044e7aec..6e5dbf862d 100644 --- a/packages/api/amplify_api/example/lib/main.dart +++ b/packages/api/amplify_api/example/lib/main.dart @@ -44,7 +44,7 @@ class _MyAppState extends State<MyApp> { } void _configureAmplify() async { - Amplify.addPlugins([AmplifyAuthCognito(), AmplifyAPI()]); + await Amplify.addPlugins([AmplifyAuthCognito(), AmplifyAPI()]); try { await Amplify.configure(amplifyconfig); diff --git a/packages/api/amplify_api/example/lib/rest_api_view.dart b/packages/api/amplify_api/example/lib/rest_api_view.dart index aeca89c97f..68f8a414f1 100644 --- a/packages/api/amplify_api/example/lib/rest_api_view.dart +++ b/packages/api/amplify_api/example/lib/rest_api_view.dart @@ -13,9 +13,8 @@ * permissions and limitations under the License. */ -import 'dart:convert'; - import 'package:amplify_flutter/amplify_flutter.dart'; +import 'package:async/async.dart'; import 'package:flutter/material.dart'; class RestApiView extends StatefulWidget { @@ -27,7 +26,7 @@ class RestApiView extends StatefulWidget { class _RestApiViewState extends State<RestApiView> { late TextEditingController _apiPathController; - late RestOperation _lastRestOperation; + late CancelableOperation _lastRestOperation; @override void initState() { @@ -39,18 +38,16 @@ class _RestApiViewState extends State<RestApiView> { void onPutPressed() async { try { - RestOperation restOperation = Amplify.API.put( - restOptions: RestOptions( - path: _apiPathController.text, - body: ascii.encode('{"name":"Mow the lawn"}'), - ), + final restOperation = Amplify.API.put( + _apiPathController.text, + body: HttpPayload.json({'name': 'Mow the lawn'}), ); _lastRestOperation = restOperation; - RestResponse response = await restOperation.response; + final response = await restOperation.response; print('Put SUCCESS'); - print(response); + print(response.decodeBody()); } on Exception catch (e) { print('Put FAILED'); print(e); @@ -59,18 +56,16 @@ class _RestApiViewState extends State<RestApiView> { void onPostPressed() async { try { - RestOperation restOperation = Amplify.API.post( - restOptions: RestOptions( - path: _apiPathController.text, - body: ascii.encode('{"name":"Mow the lawn"}'), - ), + final restOperation = Amplify.API.post( + _apiPathController.text, + body: HttpPayload.json({'name': 'Mow the lawn'}), ); _lastRestOperation = restOperation; - RestResponse response = await restOperation.response; + final response = await restOperation.response; print('Post SUCCESS'); - print(response); + print(response.decodeBody()); } on Exception catch (e) { print('Post FAILED'); print(e); @@ -79,16 +74,15 @@ class _RestApiViewState extends State<RestApiView> { void onGetPressed() async { try { - RestOperation restOperation = Amplify.API.get( - restOptions: RestOptions( - path: _apiPathController.text, - )); + final restOperation = Amplify.API.get( + _apiPathController.text, + ); _lastRestOperation = restOperation; - RestResponse response = await restOperation.response; + final response = await restOperation.response; print('Get SUCCESS'); - print(response); + print(response.decodeBody()); } on ApiException catch (e) { print('Get FAILED'); print(e.toString()); @@ -97,15 +91,14 @@ class _RestApiViewState extends State<RestApiView> { void onDeletePressed() async { try { - RestOperation restOperation = Amplify.API.delete( - restOptions: RestOptions(path: _apiPathController.text), + final restOperation = Amplify.API.delete( + _apiPathController.text, ); - _lastRestOperation = restOperation; - RestResponse response = await restOperation.response; + final response = await restOperation.response; print('Delete SUCCESS'); - print(response); + print(response.decodeBody()); } on Exception catch (e) { print('Delete FAILED'); print(e); @@ -123,15 +116,14 @@ class _RestApiViewState extends State<RestApiView> { void onHeadPressed() async { try { - RestOperation restOperation = Amplify.API.head( - restOptions: RestOptions(path: _apiPathController.text), + final restOperation = Amplify.API.head( + _apiPathController.text, ); _lastRestOperation = restOperation; - RestResponse response = await restOperation.response; + await restOperation.response; print('Head SUCCESS'); - print(response); } on ApiException catch (e) { print('Head FAILED'); print(e.toString()); @@ -140,15 +132,16 @@ class _RestApiViewState extends State<RestApiView> { void onPatchPressed() async { try { - RestOperation restOperation = Amplify.API.patch( - restOptions: RestOptions(path: _apiPathController.text), + final restOperation = Amplify.API.patch( + _apiPathController.text, + body: HttpPayload.json({'name': 'Mow the lawn'}), ); _lastRestOperation = restOperation; - RestResponse response = await restOperation.response; + final response = await restOperation.response; print('Patch SUCCESS'); - print(response); + print(response.decodeBody()); } on ApiException catch (e) { print('Patch FAILED'); print(e.toString()); diff --git a/packages/api/amplify_api/example/pubspec.yaml b/packages/api/amplify_api/example/pubspec.yaml index a57976922e..8b2d58e92d 100644 --- a/packages/api/amplify_api/example/pubspec.yaml +++ b/packages/api/amplify_api/example/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: path: ../../../auth/amplify_auth_cognito amplify_flutter: path: ../../../amplify/amplify_flutter + async: ^2.8.2 aws_common: ^0.2.0 # The following adds the Cupertino Icons font to your application. diff --git a/packages/api/amplify_api/lib/src/method_channel_api.dart b/packages/api/amplify_api/lib/src/method_channel_api.dart index 59deb7fca0..95e8f5c17d 100644 --- a/packages/api/amplify_api/lib/src/method_channel_api.dart +++ b/packages/api/amplify_api/lib/src/method_channel_api.dart @@ -14,13 +14,14 @@ */ import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import 'package:amplify_api/src/graphql/graphql_response_decoder.dart'; import 'package:amplify_api/src/graphql/graphql_subscription_event.dart'; import 'package:amplify_api/src/graphql/graphql_subscription_transformer.dart'; import 'package:amplify_core/amplify_core.dart'; - +import 'package:async/async.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -150,31 +151,19 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { } @override - GraphQLOperation<T> query<T>({required GraphQLRequest<T> request}) { - Future<GraphQLResponse<T>> response = + CancelableOperation<GraphQLResponse<T>> query<T>( + {required GraphQLRequest<T> request}) { + Future<GraphQLResponse<T>> responseFuture = _getMethodChannelResponse(methodName: 'query', request: request); - - //TODO: Cancel implementation will be added along with REST API as it is shared - GraphQLOperation<T> result = GraphQLOperation<T>( - cancel: () => cancelRequest(request.id), - response: response, - ); - - return result; + return CancelableOperation.fromFuture(responseFuture); } @override - GraphQLOperation<T> mutate<T>({required GraphQLRequest<T> request}) { - Future<GraphQLResponse<T>> response = + CancelableOperation<GraphQLResponse<T>> mutate<T>( + {required GraphQLRequest<T> request}) { + Future<GraphQLResponse<T>> responseFuture = _getMethodChannelResponse(methodName: 'mutate', request: request); - - //TODO: Cancel implementation will be added along with REST API as it is shared - GraphQLOperation<T> result = GraphQLOperation<T>( - cancel: () => cancelRequest(request.id), - response: response, - ); - - return result; + return CancelableOperation.fromFuture(responseFuture); } @override @@ -248,21 +237,73 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { } // ====== RestAPI ====== - RestOperation _restFunctionHelper( - {required String methodName, required RestOptions restOptions}) { - // Send Request cancelToken to Native - String cancelToken = UUID.getUUID(); - Future<RestResponse> futureResponse = - _callNativeRestMethod(methodName, cancelToken, restOptions); + Future<AWSStreamedHttpResponse> _restResponseHelper({ + required String methodName, + required String path, + required String cancelToken, + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) async { + Uint8List? bodyBytes; + if (body != null) { + final completer = Completer<Uint8List>(); + final sink = ByteConversionSink.withCallback( + (bytes) => completer.complete(Uint8List.fromList(bytes)), + ); + body.listen( + sink.add, + onError: completer.completeError, + onDone: sink.close, + cancelOnError: true, + ); + bodyBytes = await completer.future; + } - return RestOperation( - response: futureResponse, - cancel: () => cancelRequest(cancelToken), + final restOptions = RestOptions( + path: path, + body: bodyBytes, + apiName: apiName, + queryParameters: queryParameters, + headers: headers, + ); + return _callNativeRestMethod(methodName, cancelToken, restOptions); + } + + CancelableOperation<AWSStreamedHttpResponse> _restFunctionHelper({ + required String methodName, + required String path, + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + // Send Request cancelToken to Native + String cancelToken = uuid(); + // Ensure Content-Type header matches payload. + var modifiedHeaders = headers != null ? Map.of(headers) : null; + final contentType = body?.contentType; + if (contentType != null) { + modifiedHeaders = modifiedHeaders ?? {}; + modifiedHeaders.putIfAbsent(AWSHeaders.contentType, () => contentType); + } + final responseFuture = _restResponseHelper( + methodName: methodName, + path: path, + cancelToken: cancelToken, + body: body, + headers: modifiedHeaders, + queryParameters: queryParameters, + apiName: apiName, ); + + return CancelableOperation.fromFuture(responseFuture, + onCancel: () => cancelRequest(cancelToken)); } - Future<RestResponse> _callNativeRestMethod( + Future<AWSStreamedHttpResponse> _callNativeRestMethod( String methodName, String cancelToken, RestOptions restOptions) async { // Prepare map input Map<String, dynamic> inputsMap = <String, dynamic>{}; @@ -284,55 +325,125 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { } } - bool _shouldThrow(int statusCode) { - return statusCode < 200 || statusCode > 299; - } - - RestResponse _formatRestResponse(Map<String, dynamic> res) { + AWSStreamedHttpResponse _formatRestResponse(Map<String, dynamic> res) { final statusCode = res['statusCode'] as int; - final headers = res['headers'] as Map?; - final response = RestResponse( - data: res['data'] as Uint8List?, - headers: headers?.cast<String, String>(), - statusCode: statusCode, - ); - if (_shouldThrow(statusCode)) { - throw RestException(response); - } - return response; + // Make type-safe version of response headers. + final serializedHeaders = res['headers'] as Map?; + final headers = serializedHeaders?.cast<String, String>(); + final rawResponseBody = res['data'] as Uint8List?; + + return AWSStreamedHttpResponse( + statusCode: statusCode, + headers: headers, + body: Stream.value(rawResponseBody ?? [])); } @override - RestOperation get({required RestOptions restOptions}) { - return _restFunctionHelper(methodName: 'get', restOptions: restOptions); + CancelableOperation<AWSStreamedHttpResponse> get( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + return _restFunctionHelper( + methodName: 'get', + path: path, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ); } @override - RestOperation put({required RestOptions restOptions}) { - return _restFunctionHelper(methodName: 'put', restOptions: restOptions); + CancelableOperation<AWSStreamedHttpResponse> put( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + return _restFunctionHelper( + methodName: 'put', + path: path, + body: body, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ); } @override - RestOperation post({required RestOptions restOptions}) { - return _restFunctionHelper(methodName: 'post', restOptions: restOptions); + CancelableOperation<AWSStreamedHttpResponse> post( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + return _restFunctionHelper( + methodName: 'post', + path: path, + body: body, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ); } @override - RestOperation delete({required RestOptions restOptions}) { - return _restFunctionHelper(methodName: 'delete', restOptions: restOptions); + CancelableOperation<AWSStreamedHttpResponse> delete( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + return _restFunctionHelper( + methodName: 'delete', + path: path, + body: body, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ); } @override - RestOperation head({required RestOptions restOptions}) { - return _restFunctionHelper(methodName: 'head', restOptions: restOptions); + CancelableOperation<AWSStreamedHttpResponse> head( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + return _restFunctionHelper( + methodName: 'head', + path: path, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ); } @override - RestOperation patch({required RestOptions restOptions}) { - return _restFunctionHelper(methodName: 'patch', restOptions: restOptions); + CancelableOperation<AWSStreamedHttpResponse> patch( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + return _restFunctionHelper( + methodName: 'patch', + path: path, + body: body, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ); } - @override + /// Cancels a request with a given request ID. + @Deprecated('Use .cancel() on CancelableOperation instead.') Future<void> cancelRequest(String cancelToken) async { print('Attempting to cancel Operation $cancelToken'); diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index 9e710688b7..b2d6056f92 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -4,6 +4,7 @@ version: 1.0.0-next.0+1 homepage: https://docs.amplify.aws/lib/q/platform/flutter/ repository: https://github.com/aws-amplify/amplify-flutter/tree/next/packages/api/amplify_api issue_tracker: https://github.com/aws-amplify/amplify-flutter/issues +publish_to: none # until finalized environment: sdk: ">=2.17.0 <3.0.0" @@ -14,6 +15,7 @@ dependencies: amplify_api_ios: 1.0.0-next.0 amplify_core: 1.0.0-next.0 amplify_flutter: '>=1.0.0-next.0 <1.0.0-next.1' + async: ^2.8.2 aws_common: ^0.2.0 collection: ^1.15.0 flutter: @@ -25,7 +27,6 @@ dev_dependencies: amplify_lints: ^2.0.0 amplify_test: path: ../../amplify_test - async: ^2.6.0 build_runner: ^2.0.0 flutter_test: sdk: flutter diff --git a/packages/api/amplify_api/test/amplify_rest_api_methods_test.dart b/packages/api/amplify_api/test/amplify_rest_api_methods_test.dart index 925c940b6b..5106ada1c2 100644 --- a/packages/api/amplify_api/test/amplify_rest_api_methods_test.dart +++ b/packages/api/amplify_api/test/amplify_rest_api_methods_test.dart @@ -26,9 +26,19 @@ import 'graphql_helpers_test.dart'; const statusOK = 200; const statusBadRequest = 400; - -// Matchers -final throwsRestException = throwsA(isA<RestException>()); +const mowLawnBody = '{"name": "Mow the lawn"}'; +const hello = 'Hello from lambda!'; +final helloResponse = ascii.encode(hello); +final encodedMowLoanBody = ascii.encode(mowLawnBody); +const queryParameters = { + 'queryParameterA': 'queryValueA', + 'queryParameterB': 'queryValueB' +}; +const headers = { + 'headerA': 'headerValueA', + 'headerB': 'headerValueB', + AWSHeaders.contentType: 'text/plain' +}; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -42,184 +52,177 @@ void main() { await Amplify.addPlugin(api); }); - test('PUT returns proper response.data', () async { - var responseData = Uint8List.fromList( - '{"success": "put call succeed!","url":/items?queryParameterA=queryValueA&queryParameterB=queryValueB","body": {"name": "Mow the lawn"}}' - .codeUnits); - var body = Uint8List.fromList('{"name":"Mow the lawn"}'.codeUnits); - var queryParameters = { - 'queryParameterA': 'queryValueA', - 'queryParameterB': 'queryValueB' - }; - var headers = {'headerA': 'headerValueA', 'headerB': 'headerValueB'}; + Future<void> _assertResponse(AWSStreamedHttpResponse response) async { + final actualResponseBody = await response.decodeBody(); + expect(actualResponseBody, hello); + expect(response.statusCode, statusOK); + } + test('PUT returns proper response.data', () async { apiChannel.setMockMethodCallHandler((MethodCall methodCall) async { if (methodCall.method == 'put') { Map<dynamic, dynamic> restOptions = methodCall.arguments['restOptions'] as Map; expect(restOptions['apiName'], 'restapi'); expect(restOptions['path'], '/items'); - expect(restOptions['body'], body); + expect(restOptions['body'], encodedMowLoanBody); expect(restOptions['queryParameters'], queryParameters); expect(restOptions['headers'], headers); - - return {'data': responseData, 'statusCode': statusOK}; + return {'data': helloResponse, 'statusCode': statusOK}; } }); - RestOperation restOperation = api.put( - restOptions: RestOptions( - path: '/items', - body: body, - apiName: 'restapi', - queryParameters: queryParameters, - headers: headers, - ), + final restOperation = api.put( + '/items', + body: HttpPayload.string(mowLawnBody), + apiName: 'restapi', + queryParameters: queryParameters, + headers: headers, ); - RestResponse response = await restOperation.response; - - expect(response.data, responseData); + final response = await restOperation.value; + await _assertResponse(response); }); test('POST returns proper response.data', () async { - var responseData = Uint8List.fromList( - '{"success": "post call succeed!","url":"/items?queryParameterA=queryValueA&queryParameterB=queryValueB","body": {"name": "Mow the lawn"}}' - .codeUnits); - var body = Uint8List.fromList('{"name":"Mow the lawn"}'.codeUnits); - var queryParameters = { - 'queryParameterA': 'queryValueA', - 'queryParameterB': 'queryValueB' - }; - var headers = {'headerA': 'headerValueA', 'headerB': 'headerValueB'}; - apiChannel.setMockMethodCallHandler((MethodCall methodCall) async { if (methodCall.method == 'post') { Map<dynamic, dynamic> restOptions = methodCall.arguments['restOptions'] as Map; expect(restOptions['apiName'], 'restapi'); expect(restOptions['path'], '/items'); - expect(restOptions['body'], body); + expect(restOptions['body'], encodedMowLoanBody); expect(restOptions['queryParameters'], queryParameters); expect(restOptions['headers'], headers); - - return {'data': responseData, 'statusCode': statusOK}; + return {'data': helloResponse, 'statusCode': statusOK}; } }); - RestOperation restOperation = api.post( - restOptions: RestOptions( - path: '/items', - body: body, - apiName: 'restapi', - headers: headers, - queryParameters: queryParameters, - ), + final restOperation = api.post( + '/items', + body: HttpPayload.string(mowLawnBody), + apiName: 'restapi', + queryParameters: queryParameters, + headers: headers, ); - RestResponse response = await restOperation.response; - - expect(response.data, responseData); + final response = await restOperation.value; + await _assertResponse(response); }); test('GET returns proper response.data', () async { - var responseData = Uint8List.fromList( - '{"success":"get call succeed!","url":"/items"}'.codeUnits); - apiChannel.setMockMethodCallHandler((MethodCall methodCall) async { if (methodCall.method == 'get') { Map<dynamic, dynamic> restOptions = methodCall.arguments['restOptions'] as Map; + expect(restOptions['apiName'], 'restapi'); expect(restOptions['path'], '/items'); - - return {'data': responseData, 'statusCode': statusOK}; + expect(restOptions['queryParameters'], queryParameters); + expect(restOptions['headers'], headers); + return {'data': helloResponse, 'statusCode': statusOK}; } }); - RestOperation restOperation = api.get( - restOptions: const RestOptions( - path: '/items', - )); - - RestResponse response = await restOperation.response; + final restOperation = api.get( + '/items', + apiName: 'restapi', + queryParameters: queryParameters, + headers: headers, + ); - expect(response.data, responseData); + final response = await restOperation.value; + await _assertResponse(response); }); test('DELETE returns proper response.data', () async { - var responseData = Uint8List.fromList( - '{"success":"delete call succeed!","url":"/items"}'.codeUnits); - apiChannel.setMockMethodCallHandler((MethodCall methodCall) async { if (methodCall.method == 'delete') { Map<dynamic, dynamic> restOptions = methodCall.arguments['restOptions'] as Map; + expect(restOptions['apiName'], 'restapi'); expect(restOptions['path'], '/items'); - - return {'data': responseData, 'statusCode': statusOK}; + expect(restOptions['body'], encodedMowLoanBody); + expect(restOptions['queryParameters'], queryParameters); + expect(restOptions['headers'], headers); + return {'data': helloResponse, 'statusCode': statusOK}; } }); - RestOperation restOperation = api.delete( - restOptions: const RestOptions( - path: '/items', - )); - - RestResponse response = await restOperation.response; + final restOperation = api.delete( + '/items', + body: HttpPayload.string(mowLawnBody), + apiName: 'restapi', + queryParameters: queryParameters, + headers: headers, + ); - expect(response.data, responseData); + final response = await restOperation.value; + await _assertResponse(response); }); - test('GET Status Code Error throws proper error', () async { + test( + 'POST with form-encoded body gets proper response with response headers included', + () async { apiChannel.setMockMethodCallHandler((MethodCall methodCall) async { - if (methodCall.method == 'get') { - throw PlatformException(code: 'ApiException', details: { - 'message': 'AMPLIFY_API_MUTATE_FAILED', - 'recoverySuggestion': 'some insightful suggestion', - 'underlyingException': 'Act of God' - }); + if (methodCall.method == 'post') { + Map<dynamic, dynamic> restOptions = + methodCall.arguments['restOptions'] as Map; + expect(restOptions['apiName'], 'restapi'); + expect(restOptions['path'], '/items'); + expect(restOptions['queryParameters'], queryParameters); + expect(restOptions['headers'][AWSHeaders.contentType], + 'application/x-www-form-urlencoded'); + expect(utf8.decode(restOptions['body'] as List<int>), 'foo=bar'); + return { + 'data': helloResponse, + 'statusCode': statusOK, + 'headers': {'foo': 'bar'} + }; } }); - try { - RestOperation restOperation = api.get( - restOptions: const RestOptions( - path: '/items', - )); - await restOperation.response; - } on ApiException catch (e) { - expect(e.message, 'AMPLIFY_API_MUTATE_FAILED'); - expect(e.recoverySuggestion, 'some insightful suggestion'); - expect(e.underlyingException, 'Act of God'); - } + final restOperation = api.post( + '/items', + apiName: 'restapi', + body: HttpPayload.formFields({'foo': 'bar'}), + queryParameters: queryParameters, + ); + + final response = await restOperation.value; + expect(response.headers['foo'], 'bar'); + await _assertResponse(response); }); - test('GET exception adds the httpStatusCode to exception if available', + test( + 'POST with json-encoded body has property Content-Type and gets proper response', () async { - const statusCode = 500; - const data = 'Internal server error'; - apiChannel.setMockMethodCallHandler((MethodCall methodCall) async { - if (methodCall.method == 'get') { + if (methodCall.method == 'post') { + Map<dynamic, dynamic> restOptions = + methodCall.arguments['restOptions'] as Map; + expect(restOptions['apiName'], 'restapi'); + expect(restOptions['path'], '/items'); + expect(restOptions['queryParameters'], queryParameters); + expect( + restOptions['headers'][AWSHeaders.contentType], 'application/json'); + expect(utf8.decode(restOptions['body'] as List<int>), '{"foo":"bar"}'); return { - 'statusCode': statusCode, - 'headers': <String, String>{}, - 'data': Uint8List.fromList(data.codeUnits), + 'data': helloResponse, + 'statusCode': statusOK, + 'headers': {'foo': 'bar'} }; } }); - try { - RestOperation restOperation = api.get( - restOptions: const RestOptions( - path: '/items', - ), - ); - await restOperation.response; - } on RestException catch (e) { - expect(e.response.statusCode, 500); - expect(e.response.body, data); - } + final restOperation = api.post( + '/items', + apiName: 'restapi', + body: HttpPayload.json({'foo': 'bar'}), + queryParameters: queryParameters, + ); + + final response = await restOperation.value; + await _assertResponse(response); }); test('CANCEL success does not throw error', () async { @@ -237,50 +240,9 @@ void main() { } }); - RestOperation restOperation = api.get( - restOptions: const RestOptions( - path: '/items', - )); + final restOperation = api.get('/items'); //RestResponse response = await restOperation.response; restOperation.cancel(); }); - - group('non-2xx status code', () { - const testBody = 'test'; - const testResponseHeaders = {'key': 'value'}; - - setUpAll(() { - apiChannel.setMockMethodCallHandler((call) async { - return { - 'data': utf8.encode(testBody), - 'statusCode': statusBadRequest, - 'headers': testResponseHeaders, - }; - }); - }); - - test('throws RestException', () async { - final restOp = api.get(restOptions: const RestOptions(path: '/')); - await expectLater(restOp.response, throwsRestException); - }); - - test('has valid RestResponse', () async { - final restOp = api.get(restOptions: const RestOptions(path: '/')); - - RestException restException; - try { - await restOp.response; - fail('RestOperation should throw'); - } on Exception catch (e) { - expect(e, isA<RestException>()); - restException = e as RestException; - } - - final response = restException.response; - expect(response.statusCode, statusBadRequest); - expect(response.headers, testResponseHeaders); - expect(response.body, testBody); - }); - }); } diff --git a/packages/auth/amplify_auth_cognito/example/lib/main.dart b/packages/auth/amplify_auth_cognito/example/lib/main.dart index bf1e0a3609..fe282ac4cf 100644 --- a/packages/auth/amplify_auth_cognito/example/lib/main.dart +++ b/packages/auth/amplify_auth_cognito/example/lib/main.dart @@ -12,9 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; import 'package:amplify_api/amplify_api.dart'; import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; @@ -177,14 +175,13 @@ class _HomeScreenState extends State<HomeScreen> { try { final response = await Amplify.API .post( - restOptions: RestOptions( - path: '/hello', - body: utf8.encode(_controller.text) as Uint8List, - ), + '/hello', + body: HttpPayload.string(_controller.text), ) - .response; + .value; + final decodedBody = await response.decodeBody(); setState(() { - _greeting = response.body; + _greeting = decodedBody; }); } on Exception catch (e) { setState(() { From ef1b223dfc3d2dd68edb0b9ca8954627a9cf63cd Mon Sep 17 00:00:00 2001 From: Elijah Quartey <Equartey@users.noreply.github.com> Date: Thu, 23 Jun 2022 11:39:46 -0500 Subject: [PATCH 20/33] chore(api): API Native Bridge for .addPlugin() (#1756) --- packages/api/amplify_api/Makefile | 4 + packages/api/amplify_api/example/pubspec.yaml | 3 +- packages/api/amplify_api/lib/amplify_api.dart | 21 ++-- .../amplify_api/lib/src/api_plugin_impl.dart | 81 ++++++++++++++ .../lib/src/native_api_plugin.dart | 63 +++++++++++ .../pigeons/native_api_plugin.dart | 43 ++++++++ packages/api/amplify_api/pubspec.yaml | 10 +- .../amplify/amplify_api/AmplifyApi.kt | 52 +++++---- .../amplify_api/NativeApiPluginBindings.java | 87 +++++++++++++++ packages/api/amplify_api_android/pubspec.yaml | 3 +- .../ios/Classes/NativeApiPlugin.h | 35 ++++++ .../ios/Classes/NativeApiPlugin.m | 102 ++++++++++++++++++ .../ios/Classes/SwiftAmplifyApiPlugin.swift | 43 ++++---- .../ios/Classes/amplify_api_ios.h | 21 ++++ .../ios/amplify_api_ios.podspec | 14 +++ .../api/amplify_api_ios/ios/module.modulemap | 6 ++ packages/api/amplify_api_ios/pubspec.yaml | 3 +- 17 files changed, 526 insertions(+), 65 deletions(-) create mode 100644 packages/api/amplify_api/Makefile create mode 100644 packages/api/amplify_api/lib/src/api_plugin_impl.dart create mode 100644 packages/api/amplify_api/lib/src/native_api_plugin.dart create mode 100644 packages/api/amplify_api/pigeons/native_api_plugin.dart create mode 100644 packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java create mode 100644 packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h create mode 100644 packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m create mode 100644 packages/api/amplify_api_ios/ios/Classes/amplify_api_ios.h create mode 100644 packages/api/amplify_api_ios/ios/module.modulemap diff --git a/packages/api/amplify_api/Makefile b/packages/api/amplify_api/Makefile new file mode 100644 index 0000000000..f1c3ac38ba --- /dev/null +++ b/packages/api/amplify_api/Makefile @@ -0,0 +1,4 @@ +.PHONY: pigeons +pigeons: + flutter pub run pigeon --input pigeons/native_api_plugin.dart + flutter format --fix lib/src/native_api_plugin.dart diff --git a/packages/api/amplify_api/example/pubspec.yaml b/packages/api/amplify_api/example/pubspec.yaml index 8b2d58e92d..3303c4f462 100644 --- a/packages/api/amplify_api/example/pubspec.yaml +++ b/packages/api/amplify_api/example/pubspec.yaml @@ -32,7 +32,8 @@ dependencies: sdk: flutter dev_dependencies: - amplify_lints: ^2.0.0 + amplify_lints: + path: ../../../amplify_lints amplify_test: path: ../../../amplify_test flutter_driver: diff --git a/packages/api/amplify_api/lib/amplify_api.dart b/packages/api/amplify_api/lib/amplify_api.dart index f0ca3c2c4f..a4db7b1e97 100644 --- a/packages/api/amplify_api/lib/amplify_api.dart +++ b/packages/api/amplify_api/lib/amplify_api.dart @@ -15,9 +15,7 @@ library amplify_api_plugin; -import 'dart:io'; - -import 'package:amplify_api/src/method_channel_api.dart'; +import 'package:amplify_api/src/api_plugin_impl.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:meta/meta.dart'; @@ -32,18 +30,11 @@ export './model_subscriptions.dart'; /// {@endtemplate} abstract class AmplifyAPI extends APIPluginInterface { /// {@macro amplify_api.amplify_api} - factory AmplifyAPI({ - List<APIAuthProvider> authProviders = const [], - ModelProviderInterface? modelProvider, - }) { - if (zIsWeb || Platform.isWindows || Platform.isMacOS || Platform.isLinux) { - throw UnsupportedError('This platform is not supported yet'); - } - return AmplifyAPIMethodChannel( - authProviders: authProviders, - modelProvider: modelProvider, - ); - } + factory AmplifyAPI( + {List<APIAuthProvider> authProviders = const [], + ModelProviderInterface? modelProvider}) => + AmplifyAPIDart( + authProviders: authProviders, modelProvider: modelProvider); /// Protected constructor for subclasses. @protected diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart new file mode 100644 index 0000000000..5ac4fc36ff --- /dev/null +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -0,0 +1,81 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +library amplify_api; + +import 'dart:io'; + +import 'package:amplify_api/amplify_api.dart'; +import 'package:amplify_api/src/native_api_plugin.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter/services.dart'; + +/// {@template amplify_api.amplify_api_dart} +/// The AWS implementation of the Amplify API category. +/// {@endtemplate} +class AmplifyAPIDart extends AmplifyAPI { + late final AWSApiPluginConfig _apiConfig; + + /// The registered [APIAuthProvider] instances. + final Map<APIAuthorizationType, APIAuthProvider> _authProviders = {}; + + /// {@macro amplify_api.amplify_api_dart} + AmplifyAPIDart({ + List<APIAuthProvider> authProviders = const [], + this.modelProvider, + }) : super.protected() { + authProviders.forEach(registerAuthProvider); + } + + @override + Future<void> configure({AmplifyConfig? config}) async { + final apiConfig = config?.api?.awsPlugin; + if (apiConfig == null) { + throw const ApiException('No AWS API config found', + recoverySuggestion: 'Add API from the Amplify CLI. See ' + 'https://docs.amplify.aws/lib/graphqlapi/getting-started/q/platform/flutter/#configure-api'); + } + _apiConfig = apiConfig; + } + + @override + Future<void> addPlugin() async { + if (zIsWeb || !(Platform.isAndroid || Platform.isIOS)) { + return; + } + + final nativeBridge = NativeApiBridge(); + try { + final authProvidersList = + _authProviders.keys.map((key) => key.rawValue).toList(); + await nativeBridge.addPlugin(authProvidersList); + } on PlatformException catch (e) { + if (e.code == 'AmplifyAlreadyConfiguredException') { + throw const AmplifyAlreadyConfiguredException( + AmplifyExceptionMessages.alreadyConfiguredDefaultMessage, + recoverySuggestion: + AmplifyExceptionMessages.alreadyConfiguredDefaultSuggestion); + } + throw AmplifyException.fromMap((e.details as Map).cast()); + } + } + + @override + final ModelProviderInterface? modelProvider; + + @override + void registerAuthProvider(APIAuthProvider authProvider) { + _authProviders[authProvider.type] = authProvider; + } +} diff --git a/packages/api/amplify_api/lib/src/native_api_plugin.dart b/packages/api/amplify_api/lib/src/native_api_plugin.dart new file mode 100644 index 0000000000..e7c5af4d04 --- /dev/null +++ b/packages/api/amplify_api/lib/src/native_api_plugin.dart @@ -0,0 +1,63 @@ +// +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. +// +// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name +// @dart = 2.12 +import 'dart:async'; +import 'dart:typed_data' show Uint8List, Int32List, Int64List, Float64List; + +import 'package:flutter/foundation.dart' show WriteBuffer, ReadBuffer; +import 'package:flutter/services.dart'; + +class _NativeApiBridgeCodec extends StandardMessageCodec { + const _NativeApiBridgeCodec(); +} + +class NativeApiBridge { + /// Constructor for [NativeApiBridge]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + NativeApiBridge({BinaryMessenger? binaryMessenger}) + : _binaryMessenger = binaryMessenger; + + final BinaryMessenger? _binaryMessenger; + + static const MessageCodec<Object?> codec = _NativeApiBridgeCodec(); + + Future<void> addPlugin(List<String?> arg_authProvidersList) async { + final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>( + 'dev.flutter.pigeon.NativeApiBridge.addPlugin', codec, + binaryMessenger: _binaryMessenger); + final Map<Object?, Object?>? replyMap = await channel + .send(<Object?>[arg_authProvidersList]) as Map<Object?, Object?>?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map<Object?, Object?> error = + (replyMap['error'] as Map<Object?, Object?>?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } +} diff --git a/packages/api/amplify_api/pigeons/native_api_plugin.dart b/packages/api/amplify_api/pigeons/native_api_plugin.dart new file mode 100644 index 0000000000..a36f7397f9 --- /dev/null +++ b/packages/api/amplify_api/pigeons/native_api_plugin.dart @@ -0,0 +1,43 @@ +// +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. +// + +// ignore_for_file: avoid_positional_boolean_parameters + +@ConfigurePigeon( + PigeonOptions( + copyrightHeader: '../../../tool/license.txt', + dartOptions: DartOptions(), + dartOut: 'lib/src/native_Api_plugin.dart', + javaOptions: JavaOptions( + className: 'NativeApiPluginBindings', + package: 'com.amazonaws.amplify.amplify_api', + ), + javaOut: + '../amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java', + objcOptions: ObjcOptions( + header: 'NativeApiPlugin.h', + ), + objcHeaderOut: '../amplify_api_ios/ios/Classes/NativeApiPlugin.h', + objcSourceOut: '../amplify_api_ios/ios/Classes/NativeApiPlugin.m', + ), +) +library native_api_plugin; + +import 'package:pigeon/pigeon.dart'; + +@HostApi() +abstract class NativeApiBridge { + void addPlugin(List<String> authProvidersList); +} diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index b2d6056f92..317faa108c 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -23,14 +23,22 @@ dependencies: meta: ^1.7.0 plugin_platform_interface: ^2.0.0 +dependency_overrides: + # TODO(dnys1): Remove when pigeon is bumped + # https://github.com/flutter/flutter/issues/105090 + analyzer: ^3.0.0 + + dev_dependencies: - amplify_lints: ^2.0.0 + amplify_lints: + path: ../../amplify_lints amplify_test: path: ../../amplify_test build_runner: ^2.0.0 flutter_test: sdk: flutter mockito: ^5.0.0 + pigeon: ^3.1.5 # The following section is specific to Flutter. flutter: diff --git a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt index 02de711722..0205877bf7 100644 --- a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt +++ b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt @@ -39,7 +39,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers /** AmplifyApiPlugin */ -class AmplifyApi : FlutterPlugin, MethodCallHandler { +class AmplifyApi : FlutterPlugin, MethodCallHandler, NativeApiPluginBindings.NativeApiBridge { private companion object { /** @@ -83,6 +83,11 @@ class AmplifyApi : FlutterPlugin, MethodCallHandler { "com.amazonaws.amplify/api_observe_events" ) eventchannel!!.setStreamHandler(graphqlSubscriptionStreamHandler) + + NativeApiPluginBindings.NativeApiBridge.setup( + flutterPluginBinding.binaryMessenger, + this + ) } @Suppress("UNCHECKED_CAST") @@ -94,27 +99,6 @@ class AmplifyApi : FlutterPlugin, MethodCallHandler { if (methodName == "cancel") { onCancel(result, (call.arguments as String)) return - } else if (methodName == "addPlugin") { - try { - val authProvidersList: List<String> = - (arguments["authProviders"] as List<*>?)?.cast() ?: listOf() - val authProviders = authProvidersList.map { AuthorizationType.valueOf(it) } - if (flutterAuthProviders == null) { - flutterAuthProviders = FlutterAuthProviders(authProviders) - } - flutterAuthProviders!!.setMethodChannel(channel) - Amplify.addPlugin( - AWSApiPlugin - .builder() - .apiAuthProviders(flutterAuthProviders!!.factory) - .build() - ) - logger.info("Added API plugin") - result.success(null) - } catch (e: Exception) { - handleAddPluginException("API", e, result) - } - return } try { @@ -168,5 +152,29 @@ class AmplifyApi : FlutterPlugin, MethodCallHandler { eventchannel = null graphqlSubscriptionStreamHandler?.close() graphqlSubscriptionStreamHandler = null + + NativeApiPluginBindings.NativeApiBridge.setup( + flutterPluginBinding.binaryMessenger, + null, + ) + } + + override fun addPlugin(authProvidersList: MutableList<String>) { + try { + val authProviders = authProvidersList.map { AuthorizationType.valueOf(it) } + if (flutterAuthProviders == null) { + flutterAuthProviders = FlutterAuthProviders(authProviders) + } + flutterAuthProviders!!.setMethodChannel(channel) + Amplify.addPlugin( + AWSApiPlugin + .builder() + .apiAuthProviders(flutterAuthProviders!!.factory) + .build() + ) + logger.info("Added API plugin") + } catch (e: Exception) { + logger.error(e.message) + } } } diff --git a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java new file mode 100644 index 0000000000..d8d07f4add --- /dev/null +++ b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java @@ -0,0 +1,87 @@ +// +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. +// +// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +package com.amazonaws.amplify.amplify_api; + +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.flutter.plugin.common.BasicMessageChannel; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MessageCodec; +import io.flutter.plugin.common.StandardMessageCodec; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.HashMap; + +/** Generated class from Pigeon. */ +@SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression"}) +public class NativeApiPluginBindings { + private static class NativeApiBridgeCodec extends StandardMessageCodec { + public static final NativeApiBridgeCodec INSTANCE = new NativeApiBridgeCodec(); + private NativeApiBridgeCodec() {} + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter.*/ + public interface NativeApiBridge { + void addPlugin(@NonNull List<String> authProvidersList); + + /** The codec used by NativeApiBridge. */ + static MessageCodec<Object> getCodec() { + return NativeApiBridgeCodec.INSTANCE; + } + + /** Sets up an instance of `NativeApiBridge` to handle messages through the `binaryMessenger`. */ + static void setup(BinaryMessenger binaryMessenger, NativeApiBridge api) { + { + BasicMessageChannel<Object> channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.NativeApiBridge.addPlugin", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map<String, Object> wrapped = new HashMap<>(); + try { + ArrayList<Object> args = (ArrayList<Object>)message; + List<String> authProvidersListArg = (List<String>)args.get(0); + if (authProvidersListArg == null) { + throw new NullPointerException("authProvidersListArg unexpectedly null."); + } + api.addPlugin(authProvidersListArg); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + } + } + private static Map<String, Object> wrapError(Throwable exception) { + Map<String, Object> errorMap = new HashMap<>(); + errorMap.put("message", exception.toString()); + errorMap.put("code", exception.getClass().getSimpleName()); + errorMap.put("details", "Cause: " + exception.getCause() + ", Stacktrace: " + Log.getStackTraceString(exception)); + return errorMap; + } +} diff --git a/packages/api/amplify_api_android/pubspec.yaml b/packages/api/amplify_api_android/pubspec.yaml index 1047613c82..72272c62a5 100644 --- a/packages/api/amplify_api_android/pubspec.yaml +++ b/packages/api/amplify_api_android/pubspec.yaml @@ -14,7 +14,8 @@ dependencies: sdk: flutter dev_dependencies: - amplify_lints: ^2.0.0 + amplify_lints: + path: ../../amplify_lints flutter_test: sdk: flutter diff --git a/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h new file mode 100644 index 0000000000..7b3bad24ed --- /dev/null +++ b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h @@ -0,0 +1,35 @@ +// +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. +// +// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// See also: https://pub.dev/packages/pigeon +#import <Foundation/Foundation.h> +@protocol FlutterBinaryMessenger; +@protocol FlutterMessageCodec; +@class FlutterError; +@class FlutterStandardTypedData; + +NS_ASSUME_NONNULL_BEGIN + + +/// The codec used by NativeApiBridge. +NSObject<FlutterMessageCodec> *NativeApiBridgeGetCodec(void); + +@protocol NativeApiBridge +- (void)addPluginAuthProvidersList:(NSArray<NSString *> *)authProvidersList error:(FlutterError *_Nullable *_Nonnull)error; +@end + +extern void NativeApiBridgeSetup(id<FlutterBinaryMessenger> binaryMessenger, NSObject<NativeApiBridge> *_Nullable api); + +NS_ASSUME_NONNULL_END diff --git a/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m new file mode 100644 index 0000000000..c936591be5 --- /dev/null +++ b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m @@ -0,0 +1,102 @@ +// +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. +// +// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// See also: https://pub.dev/packages/pigeon +#import "NativeApiPlugin.h" +#import <Flutter/Flutter.h> + +#if !__has_feature(objc_arc) +#error File requires ARC to be enabled. +#endif + +static NSDictionary<NSString *, id> *wrapResult(id result, FlutterError *error) { + NSDictionary *errorDict = (NSDictionary *)[NSNull null]; + if (error) { + errorDict = @{ + @"code": (error.code ?: [NSNull null]), + @"message": (error.message ?: [NSNull null]), + @"details": (error.details ?: [NSNull null]), + }; + } + return @{ + @"result": (result ?: [NSNull null]), + @"error": errorDict, + }; +} +static id GetNullableObject(NSDictionary* dict, id key) { + id result = dict[key]; + return (result == [NSNull null]) ? nil : result; +} +static id GetNullableObjectAtIndex(NSArray* array, NSInteger key) { + id result = array[key]; + return (result == [NSNull null]) ? nil : result; +} + + + +@interface NativeApiBridgeCodecReader : FlutterStandardReader +@end +@implementation NativeApiBridgeCodecReader +@end + +@interface NativeApiBridgeCodecWriter : FlutterStandardWriter +@end +@implementation NativeApiBridgeCodecWriter +@end + +@interface NativeApiBridgeCodecReaderWriter : FlutterStandardReaderWriter +@end +@implementation NativeApiBridgeCodecReaderWriter +- (FlutterStandardWriter *)writerWithData:(NSMutableData *)data { + return [[NativeApiBridgeCodecWriter alloc] initWithData:data]; +} +- (FlutterStandardReader *)readerWithData:(NSData *)data { + return [[NativeApiBridgeCodecReader alloc] initWithData:data]; +} +@end + +NSObject<FlutterMessageCodec> *NativeApiBridgeGetCodec() { + static dispatch_once_t sPred = 0; + static FlutterStandardMessageCodec *sSharedObject = nil; + dispatch_once(&sPred, ^{ + NativeApiBridgeCodecReaderWriter *readerWriter = [[NativeApiBridgeCodecReaderWriter alloc] init]; + sSharedObject = [FlutterStandardMessageCodec codecWithReaderWriter:readerWriter]; + }); + return sSharedObject; +} + + +void NativeApiBridgeSetup(id<FlutterBinaryMessenger> binaryMessenger, NSObject<NativeApiBridge> *api) { + { + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:@"dev.flutter.pigeon.NativeApiBridge.addPlugin" + binaryMessenger:binaryMessenger + codec:NativeApiBridgeGetCodec() ]; + if (api) { + NSCAssert([api respondsToSelector:@selector(addPluginAuthProvidersList:error:)], @"NativeApiBridge api (%@) doesn't respond to @selector(addPluginAuthProvidersList:error:)", api); + [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { + NSArray *args = message; + NSArray<NSString *> *arg_authProvidersList = GetNullableObjectAtIndex(args, 0); + FlutterError *error; + [api addPluginAuthProvidersList:arg_authProvidersList error:&error]; + callback(wrapResult(nil, error)); + }]; + } + else { + [channel setMessageHandler:nil]; + } + } +} diff --git a/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift b/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift index 7ad1accd1a..63ce5c373c 100644 --- a/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift +++ b/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift @@ -20,7 +20,7 @@ import AmplifyPlugins import amplify_flutter_ios import AWSPluginsCore -public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin { +public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin, NativeApiBridge { private let bridge: ApiBridge private let graphQLSubscriptionsStreamHandler: GraphQLSubscriptionsStreamHandler static var methodChannel: FlutterMethodChannel! @@ -43,6 +43,7 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin { let instance = SwiftAmplifyApiPlugin() eventchannel.setStreamHandler(instance.graphQLSubscriptionsStreamHandler) registrar.addMethodCallDelegate(instance, channel: methodChannel) + NativeApiBridgeSetup(registrar.messenger(), instance) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { @@ -62,33 +63,26 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin { let arguments = try FlutterApiRequest.getMap(args: callArgs) - if method == "addPlugin"{ - let authProvidersList = arguments["authProviders"] as? [String] ?? [] - let authProviders = authProvidersList.compactMap { - AWSAuthorizationType(rawValue: $0) - } - addPlugin(authProviders: authProviders, result: result) - return - } - try innerHandle(method: method, arguments: arguments, result: result) } catch { print("Failed to parse query arguments with \(error)") FlutterApiErrorHandler.handleApiError(error: APIError(error: error), flutterResult: result) } } - - private func addPlugin(authProviders: [AWSAuthorizationType], result: FlutterResult) { + + public func addPluginAuthProvidersList(_ authProvidersList: [String], error: AutoreleasingUnsafeMutablePointer<FlutterError?>) { do { + let authProviders = authProvidersList.compactMap { + AWSAuthorizationType(rawValue: $0) + } try Amplify.add( plugin: AWSAPIPlugin( sessionFactory: FlutterURLSessionBehaviorFactory(), apiAuthProviderFactory: FlutterAuthProviders(authProviders))) - result(true) } catch let apiError as APIError { - ErrorUtil.postErrorToFlutterChannel( - result: result, - errorCode: "APIException", + error.pointee = FlutterError( + code: "APIException", + message: apiError.localizedDescription, details: [ "message": apiError.errorDescription, "recoverySuggestion": apiError.recoverySuggestion, @@ -100,20 +94,21 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin { if case .amplifyAlreadyConfigured = configError { errorCode = "AmplifyAlreadyConfiguredException" } - ErrorUtil.postErrorToFlutterChannel( - result: result, - errorCode: errorCode, + error.pointee = FlutterError( + code: errorCode, + message: configError.localizedDescription, details: [ "message": configError.errorDescription, "recoverySuggestion": configError.recoverySuggestion, "underlyingError": configError.underlyingError?.localizedDescription ?? "" ] ) - } catch { - ErrorUtil.postErrorToFlutterChannel( - result: result, - errorCode: "UNKNOWN", - details: ["message": error.localizedDescription]) + } catch let e { + error.pointee = FlutterError( + code: "UNKNOWN", + message: e.localizedDescription, + details: nil + ) } } diff --git a/packages/api/amplify_api_ios/ios/Classes/amplify_api_ios.h b/packages/api/amplify_api_ios/ios/Classes/amplify_api_ios.h new file mode 100644 index 0000000000..0b890efd4f --- /dev/null +++ b/packages/api/amplify_api_ios/ios/Classes/amplify_api_ios.h @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +#ifndef amplify_api_ios_h +#define amplify_api_ios_h + +#import "NativeApiPlugin.h" +#import "AmplifyApi.h" + +#endif /* amplify_api_ios_h */ diff --git a/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec b/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec index 181063b97c..276c97012b 100644 --- a/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec +++ b/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec @@ -21,6 +21,20 @@ The API module for Amplify Flutter. s.platform = :ios, '11.0' s.swift_version = '5.0' + # Use a custom module map with a manually written umbrella header. + # + # Since we use `package:pigeon` to generate our platform interface + # in ObjC, and since the rest of the module is written in Swift, we + # fall victim to this issue: https://github.com/CocoaPods/CocoaPods/issues/10544 + # + # This is because we have an ObjC -> Swift -> ObjC import cycle: + # ApiPlugin -> SwiftAmplifyApiPlugin -> NativeApiPlugin + # + # The easiest solution to this problem is to create the umbrella + # header which would otherwise be auto-generated by Cocoapods but + # name it what's expected by the Swift compiler (amplify_api_ios.h). + s.module_map = 'module.modulemap' + # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } end diff --git a/packages/api/amplify_api_ios/ios/module.modulemap b/packages/api/amplify_api_ios/ios/module.modulemap new file mode 100644 index 0000000000..acac87c311 --- /dev/null +++ b/packages/api/amplify_api_ios/ios/module.modulemap @@ -0,0 +1,6 @@ +framework module amplify_api_ios { + umbrella header "amplify_api_ios.h" + + export * + module * { export * } +} diff --git a/packages/api/amplify_api_ios/pubspec.yaml b/packages/api/amplify_api_ios/pubspec.yaml index a9dbcb40c4..3ce5b1f2b6 100644 --- a/packages/api/amplify_api_ios/pubspec.yaml +++ b/packages/api/amplify_api_ios/pubspec.yaml @@ -15,7 +15,8 @@ dependencies: sdk: flutter dev_dependencies: - amplify_lints: ^2.0.0 + amplify_lints: + path: ../../amplify_lints flutter_test: sdk: flutter From 4621a666ea2dab8833104601275b41c1c680d096 Mon Sep 17 00:00:00 2001 From: Elijah Quartey <Equartey@users.noreply.github.com> Date: Mon, 27 Jun 2022 11:43:25 -0500 Subject: [PATCH 21/33] chore(api): API Pigeon update (#1813) --- .../lib/src/native_api_plugin.dart | 2 +- .../pigeons/native_api_plugin.dart | 1 + packages/api/amplify_api/pubspec.yaml | 8 +----- .../amplify/amplify_api/AmplifyApi.kt | 7 +++++- .../amplify_api/NativeApiPluginBindings.java | 25 +++++++++++++++---- .../ios/Classes/NativeApiPlugin.h | 4 +-- .../ios/Classes/NativeApiPlugin.m | 10 ++++---- .../ios/Classes/SwiftAmplifyApiPlugin.swift | 9 ++++--- .../ios/amplify_api_ios.podspec | 6 +++-- 9 files changed, 45 insertions(+), 27 deletions(-) diff --git a/packages/api/amplify_api/lib/src/native_api_plugin.dart b/packages/api/amplify_api/lib/src/native_api_plugin.dart index e7c5af4d04..3ff74bd774 100644 --- a/packages/api/amplify_api/lib/src/native_api_plugin.dart +++ b/packages/api/amplify_api/lib/src/native_api_plugin.dart @@ -12,7 +12,7 @@ // express or implied. See the License for the specific language governing // permissions and limitations under the License. // -// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// Autogenerated from Pigeon (v3.2.0), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name // @dart = 2.12 diff --git a/packages/api/amplify_api/pigeons/native_api_plugin.dart b/packages/api/amplify_api/pigeons/native_api_plugin.dart index a36f7397f9..0e54029724 100644 --- a/packages/api/amplify_api/pigeons/native_api_plugin.dart +++ b/packages/api/amplify_api/pigeons/native_api_plugin.dart @@ -39,5 +39,6 @@ import 'package:pigeon/pigeon.dart'; @HostApi() abstract class NativeApiBridge { + @async void addPlugin(List<String> authProvidersList); } diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index 317faa108c..e7392862e1 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -23,12 +23,6 @@ dependencies: meta: ^1.7.0 plugin_platform_interface: ^2.0.0 -dependency_overrides: - # TODO(dnys1): Remove when pigeon is bumped - # https://github.com/flutter/flutter/issues/105090 - analyzer: ^3.0.0 - - dev_dependencies: amplify_lints: path: ../../amplify_lints @@ -38,7 +32,7 @@ dev_dependencies: flutter_test: sdk: flutter mockito: ^5.0.0 - pigeon: ^3.1.5 + pigeon: ^3.1.6 # The following section is specific to Flutter. flutter: diff --git a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt index 0205877bf7..e49a66932a 100644 --- a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt +++ b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/AmplifyApi.kt @@ -159,7 +159,10 @@ class AmplifyApi : FlutterPlugin, MethodCallHandler, NativeApiPluginBindings.Nat ) } - override fun addPlugin(authProvidersList: MutableList<String>) { + override fun addPlugin( + authProvidersList: MutableList<String>, + result: NativeApiPluginBindings.Result<Void> + ) { try { val authProviders = authProvidersList.map { AuthorizationType.valueOf(it) } if (flutterAuthProviders == null) { @@ -173,8 +176,10 @@ class AmplifyApi : FlutterPlugin, MethodCallHandler, NativeApiPluginBindings.Nat .build() ) logger.info("Added API plugin") + result.success(null) } catch (e: Exception) { logger.error(e.message) + result.error(e) } } } diff --git a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java index d8d07f4add..70c59352c8 100644 --- a/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java +++ b/packages/api/amplify_api_android/android/src/main/kotlin/com/amazonaws/amplify/amplify_api/NativeApiPluginBindings.java @@ -12,7 +12,7 @@ // express or implied. See the License for the specific language governing // permissions and limitations under the License. // -// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// Autogenerated from Pigeon (v3.2.0), do not edit directly. // See also: https://pub.dev/packages/pigeon package com.amazonaws.amplify.amplify_api; @@ -35,6 +35,11 @@ /** Generated class from Pigeon. */ @SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression"}) public class NativeApiPluginBindings { + + public interface Result<T> { + void success(T result); + void error(Throwable error); + } private static class NativeApiBridgeCodec extends StandardMessageCodec { public static final NativeApiBridgeCodec INSTANCE = new NativeApiBridgeCodec(); private NativeApiBridgeCodec() {} @@ -42,7 +47,7 @@ private NativeApiBridgeCodec() {} /** Generated interface from Pigeon that represents a handler of messages from Flutter.*/ public interface NativeApiBridge { - void addPlugin(@NonNull List<String> authProvidersList); + void addPlugin(@NonNull List<String> authProvidersList, Result<Void> result); /** The codec used by NativeApiBridge. */ static MessageCodec<Object> getCodec() { @@ -63,13 +68,23 @@ static void setup(BinaryMessenger binaryMessenger, NativeApiBridge api) { if (authProvidersListArg == null) { throw new NullPointerException("authProvidersListArg unexpectedly null."); } - api.addPlugin(authProvidersListArg); - wrapped.put("result", null); + Result<Void> resultCallback = new Result<Void>() { + public void success(Void result) { + wrapped.put("result", null); + reply.reply(wrapped); + } + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.addPlugin(authProvidersListArg, resultCallback); } catch (Error | RuntimeException exception) { wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); } - reply.reply(wrapped); }); } else { channel.setMessageHandler(null); diff --git a/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h index 7b3bad24ed..cf89fcb539 100644 --- a/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h +++ b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.h @@ -12,7 +12,7 @@ // express or implied. See the License for the specific language governing // permissions and limitations under the License. // -// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// Autogenerated from Pigeon (v3.2.0), do not edit directly. // See also: https://pub.dev/packages/pigeon #import <Foundation/Foundation.h> @protocol FlutterBinaryMessenger; @@ -27,7 +27,7 @@ NS_ASSUME_NONNULL_BEGIN NSObject<FlutterMessageCodec> *NativeApiBridgeGetCodec(void); @protocol NativeApiBridge -- (void)addPluginAuthProvidersList:(NSArray<NSString *> *)authProvidersList error:(FlutterError *_Nullable *_Nonnull)error; +- (void)addPluginAuthProvidersList:(NSArray<NSString *> *)authProvidersList completion:(void(^)(FlutterError *_Nullable))completion; @end extern void NativeApiBridgeSetup(id<FlutterBinaryMessenger> binaryMessenger, NSObject<NativeApiBridge> *_Nullable api); diff --git a/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m index c936591be5..bae599aa4b 100644 --- a/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m +++ b/packages/api/amplify_api_ios/ios/Classes/NativeApiPlugin.m @@ -12,7 +12,7 @@ // express or implied. See the License for the specific language governing // permissions and limitations under the License. // -// Autogenerated from Pigeon (v3.1.5), do not edit directly. +// Autogenerated from Pigeon (v3.2.0), do not edit directly. // See also: https://pub.dev/packages/pigeon #import "NativeApiPlugin.h" #import <Flutter/Flutter.h> @@ -86,13 +86,13 @@ void NativeApiBridgeSetup(id<FlutterBinaryMessenger> binaryMessenger, NSObject<N binaryMessenger:binaryMessenger codec:NativeApiBridgeGetCodec() ]; if (api) { - NSCAssert([api respondsToSelector:@selector(addPluginAuthProvidersList:error:)], @"NativeApiBridge api (%@) doesn't respond to @selector(addPluginAuthProvidersList:error:)", api); + NSCAssert([api respondsToSelector:@selector(addPluginAuthProvidersList:completion:)], @"NativeApiBridge api (%@) doesn't respond to @selector(addPluginAuthProvidersList:completion:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; NSArray<NSString *> *arg_authProvidersList = GetNullableObjectAtIndex(args, 0); - FlutterError *error; - [api addPluginAuthProvidersList:arg_authProvidersList error:&error]; - callback(wrapResult(nil, error)); + [api addPluginAuthProvidersList:arg_authProvidersList completion:^(FlutterError *_Nullable error) { + callback(wrapResult(nil, error)); + }]; }]; } else { diff --git a/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift b/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift index 63ce5c373c..01c14b8e0c 100644 --- a/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift +++ b/packages/api/amplify_api_ios/ios/Classes/SwiftAmplifyApiPlugin.swift @@ -70,7 +70,7 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin, NativeApiBridge { } } - public func addPluginAuthProvidersList(_ authProvidersList: [String], error: AutoreleasingUnsafeMutablePointer<FlutterError?>) { + public func addPluginAuthProvidersList(_ authProvidersList: [String]) async -> FlutterError? { do { let authProviders = authProvidersList.compactMap { AWSAuthorizationType(rawValue: $0) @@ -79,8 +79,9 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin, NativeApiBridge { plugin: AWSAPIPlugin( sessionFactory: FlutterURLSessionBehaviorFactory(), apiAuthProviderFactory: FlutterAuthProviders(authProviders))) + return nil } catch let apiError as APIError { - error.pointee = FlutterError( + return FlutterError( code: "APIException", message: apiError.localizedDescription, details: [ @@ -94,7 +95,7 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin, NativeApiBridge { if case .amplifyAlreadyConfigured = configError { errorCode = "AmplifyAlreadyConfiguredException" } - error.pointee = FlutterError( + return FlutterError( code: errorCode, message: configError.localizedDescription, details: [ @@ -104,7 +105,7 @@ public class SwiftAmplifyApiPlugin: NSObject, FlutterPlugin, NativeApiBridge { ] ) } catch let e { - error.pointee = FlutterError( + return FlutterError( code: "UNKNOWN", message: e.localizedDescription, details: nil diff --git a/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec b/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec index 276c97012b..f5a6147bff 100644 --- a/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec +++ b/packages/api/amplify_api_ios/ios/amplify_api_ios.podspec @@ -18,8 +18,10 @@ The API module for Amplify Flutter. s.dependency 'Amplify', '1.23.0' s.dependency 'AmplifyPlugins/AWSAPIPlugin', '1.23.0' s.dependency 'amplify_flutter_ios' - s.platform = :ios, '11.0' - s.swift_version = '5.0' + + # These are needed to support async/await with pigeon + s.platform = :ios, '13.0' + s.swift_version = '5.5' # Use a custom module map with a manually written umbrella header. # From 438c23662c11ad05c9df1a0aec0e5b954cf19831 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Mon, 27 Jun 2022 14:28:39 -0800 Subject: [PATCH 22/33] feat(api): REST methods in dart with auth mode none (#1783) --- .../amplify_flutter/lib/src/hybrid_impl.dart | 1 + .../lib/src/amplify_api_config.dart | 74 ++++++++ .../amplify_authorization_rest_client.dart | 58 +++++++ .../amplify_api/lib/src/api_plugin_impl.dart | 163 +++++++++++++++++- .../lib/src/method_channel_api.dart | 10 +- packages/api/amplify_api/lib/src/util.dart | 32 ++++ packages/api/amplify_api/pubspec.yaml | 1 + .../test/amplify_dart_rest_methods_test.dart | 103 +++++++++++ .../test_data/fake_amplify_configuration.dart | 79 +++++++++ packages/api/amplify_api/test/util_test.dart | 42 +++++ 10 files changed, 554 insertions(+), 9 deletions(-) create mode 100644 packages/api/amplify_api/lib/src/amplify_api_config.dart create mode 100644 packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart create mode 100644 packages/api/amplify_api/lib/src/util.dart create mode 100644 packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart create mode 100644 packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart create mode 100644 packages/api/amplify_api/test/util_test.dart diff --git a/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart b/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart index a8c91af3a0..5eb3f1257e 100644 --- a/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart +++ b/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart @@ -34,6 +34,7 @@ class AmplifyHybridImpl extends AmplifyClassImpl { ); await Future.wait( [ + ...API.plugins, ...Auth.plugins, ].map((p) => p.configure(config: amplifyConfig)), eagerError: true, diff --git a/packages/api/amplify_api/lib/src/amplify_api_config.dart b/packages/api/amplify_api/lib/src/amplify_api_config.dart new file mode 100644 index 0000000000..960f11bf9b --- /dev/null +++ b/packages/api/amplify_api/lib/src/amplify_api_config.dart @@ -0,0 +1,74 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. + +import 'package:amplify_core/amplify_core.dart'; +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; + +const _slash = '/'; + +@internal +class EndpointConfig with AWSEquatable<EndpointConfig> { + const EndpointConfig(this.name, this.config); + + final String name; + final AWSApiConfig config; + + @override + List<Object?> get props => [name, config]; + + /// Gets the host with environment path prefix from Amplify config and combines + /// with [path] and [queryParameters] to return a full [Uri]. + Uri getUri(String path, Map<String, dynamic>? queryParameters) { + final parsed = Uri.parse(config.endpoint); + // Remove leading slashes which are suggested in public documentation. + // https://docs.amplify.aws/lib/restapi/getting-started/q/platform/flutter/#make-a-post-request + if (path.startsWith(_slash)) { + path = path.substring(1); + } + // Avoid URI-encoding slashes in path from caller. + final pathSegmentsFromPath = path.split(_slash); + return parsed.replace(pathSegments: [ + ...parsed.pathSegments, + ...pathSegmentsFromPath, + ], queryParameters: queryParameters); + } +} + +@internal +extension AWSApiPluginConfigHelpers on AWSApiPluginConfig { + EndpointConfig getEndpoint({ + required EndpointType type, + String? apiName, + }) { + final typeConfigs = + entries.where((config) => config.value.endpointType == type); + if (apiName != null) { + final config = typeConfigs.firstWhere( + (config) => config.key == apiName, + orElse: () => throw ApiException( + 'No API endpoint found matching apiName $apiName.', + ), + ); + return EndpointConfig(config.key, config.value); + } + final onlyConfig = typeConfigs.singleOrNull; + if (onlyConfig == null) { + throw const ApiException( + 'Multiple API endpoints defined. Pass apiName parameter to specify ' + 'which one to use.', + ); + } + return EndpointConfig(onlyConfig.key, onlyConfig.value); + } +} diff --git a/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart new file mode 100644 index 0000000000..e58885385c --- /dev/null +++ b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart @@ -0,0 +1,58 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:async'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +/// Implementation of http [http.Client] that authorizes HTTP requests with +/// Amplify. +@internal +class AmplifyAuthorizationRestClient extends http.BaseClient + implements Closeable { + /// Determines how requests with this client are authorized. + final AWSApiConfig endpointConfig; + final http.Client _baseClient; + final bool _useDefaultBaseClient; + + /// Provide an [AWSApiConfig] which will determine how requests from this + /// client are authorized. + AmplifyAuthorizationRestClient({ + required this.endpointConfig, + http.Client? baseClient, + }) : _useDefaultBaseClient = baseClient == null, + _baseClient = baseClient ?? http.Client(); + + /// Implementation of [send] that authorizes any request without "Authorization" + /// header already set. + @override + Future<http.StreamedResponse> send(http.BaseRequest request) async => + _baseClient.send(_authorizeRequest(request)); + + @override + void close() { + if (_useDefaultBaseClient) _baseClient.close(); + } + + http.BaseRequest _authorizeRequest(http.BaseRequest request) { + if (!request.headers.containsKey(AWSHeaders.authorization) && + endpointConfig.authorizationType != APIAuthorizationType.none) { + // ignore: todo + // TODO: Use auth providers from core to transform the request. + } + return request; + } +} diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index 5ac4fc36ff..0926c0a462 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -19,13 +19,25 @@ import 'dart:io'; import 'package:amplify_api/amplify_api.dart'; import 'package:amplify_api/src/native_api_plugin.dart'; import 'package:amplify_core/amplify_core.dart'; +import 'package:async/async.dart'; import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +import 'amplify_api_config.dart'; +import 'amplify_authorization_rest_client.dart'; +import 'util.dart'; /// {@template amplify_api.amplify_api_dart} /// The AWS implementation of the Amplify API category. /// {@endtemplate} class AmplifyAPIDart extends AmplifyAPI { late final AWSApiPluginConfig _apiConfig; + final http.Client? _baseHttpClient; + + /// A map of the keys from the Amplify API config to HTTP clients to use for + /// requests to that endpoint. + final Map<String, AmplifyAuthorizationRestClient> _clientPool = {}; /// The registered [APIAuthProvider] instances. final Map<APIAuthorizationType, APIAuthProvider> _authProviders = {}; @@ -33,8 +45,10 @@ class AmplifyAPIDart extends AmplifyAPI { /// {@macro amplify_api.amplify_api_dart} AmplifyAPIDart({ List<APIAuthProvider> authProviders = const [], + http.Client? baseHttpClient, this.modelProvider, - }) : super.protected() { + }) : _baseHttpClient = baseHttpClient, + super.protected() { authProviders.forEach(registerAuthProvider); } @@ -71,6 +85,43 @@ class AmplifyAPIDart extends AmplifyAPI { } } + /// Returns the HTTP client to be used for REST operations. + /// + /// Use [apiName] if there are multiple REST endpoints. + @visibleForTesting + http.Client getRestClient({String? apiName}) { + final endpoint = _apiConfig.getEndpoint( + type: EndpointType.rest, + apiName: apiName, + ); + return _clientPool[endpoint.name] ??= AmplifyAuthorizationRestClient( + endpointConfig: endpoint.config, + baseClient: _baseHttpClient, + ); + } + + Uri _getRestUri( + String path, String? apiName, Map<String, dynamic>? queryParameters) { + final endpoint = _apiConfig.getEndpoint( + type: EndpointType.rest, + apiName: apiName, + ); + return endpoint.getUri(path, queryParameters); + } + + /// NOTE: http does not support request abort https://github.com/dart-lang/http/issues/424. + /// For now, just make a [CancelableOperation] to cancel the future. + /// To actually abort calls at network layer, need to call in + /// dart:io/dart:html or other library with custom http default Client() implementation. + CancelableOperation<T> _makeCancelable<T>(Future<T> responseFuture) { + return CancelableOperation.fromFuture(responseFuture); + } + + CancelableOperation<AWSStreamedHttpResponse> _prepareRestResponse( + Future<AWSStreamedHttpResponse> responseFuture) { + return _makeCancelable(responseFuture); + } + @override final ModelProviderInterface? modelProvider; @@ -78,4 +129,114 @@ class AmplifyAPIDart extends AmplifyAPI { void registerAuthProvider(APIAuthProvider authProvider) { _authProviders[authProvider.type] = authProvider; } + + // ====== REST ======= + + @override + CancelableOperation<AWSStreamedHttpResponse> delete( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + final uri = _getRestUri(path, apiName, queryParameters); + final client = getRestClient(apiName: apiName); + return _prepareRestResponse(AWSStreamedHttpRequest.delete( + uri, + body: body ?? HttpPayload.empty(), + headers: addContentTypeToHeaders(headers, body), + ).send(client)); + } + + @override + CancelableOperation<AWSStreamedHttpResponse> get( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + final uri = _getRestUri(path, apiName, queryParameters); + final client = getRestClient(apiName: apiName); + return _prepareRestResponse( + AWSHttpRequest.get( + uri, + headers: headers, + ).send(client), + ); + } + + @override + CancelableOperation<AWSStreamedHttpResponse> head( + String path, { + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + final uri = _getRestUri(path, apiName, queryParameters); + final client = getRestClient(apiName: apiName); + return _prepareRestResponse( + AWSHttpRequest.head( + uri, + headers: headers, + ).send(client), + ); + } + + @override + CancelableOperation<AWSStreamedHttpResponse> patch( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + final uri = _getRestUri(path, apiName, queryParameters); + final client = getRestClient(apiName: apiName); + return _prepareRestResponse( + AWSStreamedHttpRequest.patch( + uri, + headers: addContentTypeToHeaders(headers, body), + body: body ?? HttpPayload.empty(), + ).send(client), + ); + } + + @override + CancelableOperation<AWSStreamedHttpResponse> post( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + final uri = _getRestUri(path, apiName, queryParameters); + final client = getRestClient(apiName: apiName); + return _prepareRestResponse( + AWSStreamedHttpRequest.post( + uri, + headers: addContentTypeToHeaders(headers, body), + body: body ?? HttpPayload.empty(), + ).send(client), + ); + } + + @override + CancelableOperation<AWSStreamedHttpResponse> put( + String path, { + HttpPayload? body, + Map<String, String>? headers, + Map<String, String>? queryParameters, + String? apiName, + }) { + final uri = _getRestUri(path, apiName, queryParameters); + final client = getRestClient(apiName: apiName); + return _prepareRestResponse( + AWSStreamedHttpRequest.put( + uri, + headers: addContentTypeToHeaders(headers, body), + body: body ?? HttpPayload.empty(), + ).send(client), + ); + } } diff --git a/packages/api/amplify_api/lib/src/method_channel_api.dart b/packages/api/amplify_api/lib/src/method_channel_api.dart index 95e8f5c17d..c0285a6305 100644 --- a/packages/api/amplify_api/lib/src/method_channel_api.dart +++ b/packages/api/amplify_api/lib/src/method_channel_api.dart @@ -26,6 +26,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../amplify_api.dart'; +import 'util.dart'; part 'auth_token.dart'; @@ -282,19 +283,12 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { }) { // Send Request cancelToken to Native String cancelToken = uuid(); - // Ensure Content-Type header matches payload. - var modifiedHeaders = headers != null ? Map.of(headers) : null; - final contentType = body?.contentType; - if (contentType != null) { - modifiedHeaders = modifiedHeaders ?? {}; - modifiedHeaders.putIfAbsent(AWSHeaders.contentType, () => contentType); - } final responseFuture = _restResponseHelper( methodName: methodName, path: path, cancelToken: cancelToken, body: body, - headers: modifiedHeaders, + headers: addContentTypeToHeaders(headers, body), queryParameters: queryParameters, apiName: apiName, ); diff --git a/packages/api/amplify_api/lib/src/util.dart b/packages/api/amplify_api/lib/src/util.dart new file mode 100644 index 0000000000..d91d58ce48 --- /dev/null +++ b/packages/api/amplify_api/lib/src/util.dart @@ -0,0 +1,32 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'package:amplify_core/amplify_core.dart'; +import 'package:meta/meta.dart'; + +/// Sets the 'Content-Type' of headers to match the [HttpPayload] body. +@internal +Map<String, String>? addContentTypeToHeaders( + Map<String, String>? headers, + HttpPayload? body, +) { + final contentType = body?.contentType; + if (contentType == null) { + return headers; + } + // Create new map to avoid modifying input headers which may be unmodifiable. + final modifiedHeaders = Map<String, String>.of(headers ?? {}); + modifiedHeaders.putIfAbsent(AWSHeaders.contentType, () => contentType); + return modifiedHeaders; +} diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index e7392862e1..c82797fc29 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: collection: ^1.15.0 flutter: sdk: flutter + http: ^0.13.4 meta: ^1.7.0 plugin_platform_interface: ^2.0.0 diff --git a/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart b/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart new file mode 100644 index 0000000000..d8c5162377 --- /dev/null +++ b/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart @@ -0,0 +1,103 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +import 'dart:convert'; + +import 'package:amplify_api/amplify_api.dart'; +import 'package:amplify_api/src/api_plugin_impl.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:async/async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'test_data/fake_amplify_configuration.dart'; + +const _expectedRestResponseBody = '"Hello from Lambda!"'; +const _pathThatShouldFail = 'notHere'; + +class MockAmplifyAPI extends AmplifyAPIDart { + @override + http.Client getRestClient({String? apiName}) => MockClient((request) async { + if (request.body.isNotEmpty) { + expect(request.headers['Content-Type'], 'application/json'); + } + if (request.url.path.contains(_pathThatShouldFail)) { + return http.Response('Not found', 404); + } + return http.Response(_expectedRestResponseBody, 200); + }); +} + +void main() { + late AmplifyAPI api; + + setUpAll(() async { + await Amplify.addPlugin(MockAmplifyAPI()); + await Amplify.configure(amplifyconfig); + }); + group('REST API', () { + Future<void> _verifyRestOperation( + CancelableOperation<AWSStreamedHttpResponse> operation, + ) async { + final response = + await operation.value.timeout(const Duration(seconds: 3)); + final body = await response.decodeBody(); + expect(body, _expectedRestResponseBody); + expect(response.statusCode, 200); + } + + test('get() should get 200', () async { + final operation = Amplify.API.get('items'); + await _verifyRestOperation(operation); + }); + + test('head() should get 200', () async { + final operation = Amplify.API.head('items'); + final response = await operation.value; + expect(response.statusCode, 200); + }); + + test('patch() should get 200', () async { + final operation = Amplify.API + .patch('items', body: HttpPayload.json({'name': 'Mow the lawn'})); + await _verifyRestOperation(operation); + }); + + test('post() should get 200', () async { + final operation = Amplify.API + .post('items', body: HttpPayload.json({'name': 'Mow the lawn'})); + await _verifyRestOperation(operation); + }); + + test('put() should get 200', () async { + final operation = Amplify.API + .put('items', body: HttpPayload.json({'name': 'Mow the lawn'})); + await _verifyRestOperation(operation); + }); + + test('delete() should get 200', () async { + final operation = Amplify.API + .delete('items', body: HttpPayload.json({'name': 'Mow the lawn'})); + await _verifyRestOperation(operation); + }); + + test('canceled request should never resolve', () async { + final operation = Amplify.API.get('items'); + operation.cancel(); + operation.then((p0) => fail('Request should have been cancelled.')); + await operation.valueOrCancellation(); + expect(operation.isCanceled, isTrue); + }); + }); +} diff --git a/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart b/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart new file mode 100644 index 0000000000..7b8fd53be0 --- /dev/null +++ b/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart @@ -0,0 +1,79 @@ +const amplifyconfig = '''{ + "UserAgent": "aws-amplify-cli/2.0", + "Version": "1.0", + "api": { + "plugins": { + "awsAPIPlugin": { + "apiIntegrationTestGraphQL": { + "endpointType": "GraphQL", + "endpoint": "https://abc123.appsync-api.us-east-1.amazonaws.com/graphql", + "region": "us-east-1", + "authorizationType": "API_KEY", + "apiKey": "abc123" + }, + "api123": { + "endpointType": "REST", + "endpoint": "https://abc123.execute-api.us-east-1.amazonaws.com/test", + "region": "us-east-1", + "authorizationType": "AWS_IAM" + } + } + } + }, + "auth": { + "plugins": { + "awsCognitoAuthPlugin": { + "UserAgent": "aws-amplify-cli/0.1.0", + "Version": "0.1.0", + "IdentityManager": { + "Default": {} + }, + "AppSync": { + "Default": { + "ApiUrl": "https://abc123.appsync-api.us-east-1.amazonaws.com/graphql", + "Region": "us-east-1", + "AuthMode": "API_KEY", + "ApiKey": "abc123", + "ClientDatabasePrefix": "apiIntegrationTestGraphQL_API_KEY" + } + }, + "CredentialsProvider": { + "CognitoIdentity": { + "Default": { + "PoolId": "us-east-1:abc123", + "Region": "us-east-1" + } + } + }, + "CognitoUserPool": { + "Default": { + "PoolId": "us-east-1_abc123", + "AppClientId": "abc123", + "Region": "us-east-1" + } + }, + "Auth": { + "Default": { + "authenticationFlowType": "USER_SRP_AUTH", + "socialProviders": [], + "usernameAttributes": [], + "signupAttributes": [ + "EMAIL" + ], + "passwordProtectionSettings": { + "passwordPolicyMinLength": 8, + "passwordPolicyCharacters": [] + }, + "mfaConfiguration": "OFF", + "mfaTypes": [ + "SMS" + ], + "verificationMechanisms": [ + "EMAIL" + ] + } + } + } + } + } +}'''; diff --git a/packages/api/amplify_api/test/util_test.dart b/packages/api/amplify_api/test/util_test.dart new file mode 100644 index 0000000000..062b8f7276 --- /dev/null +++ b/packages/api/amplify_api/test/util_test.dart @@ -0,0 +1,42 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. + +import 'package:amplify_api/src/util.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('addContentTypeToHeaders', () { + test('adds Content-Type header from payload', () { + final resultHeaders = addContentTypeToHeaders( + null, HttpPayload.json({'name': 'now the lawn'})); + expect(resultHeaders?['Content-Type'], 'application/json'); + }); + + test('no-op when payload null', () { + final inputHeaders = {'foo': 'bar'}; + final resultHeaders = addContentTypeToHeaders(inputHeaders, null); + expect(resultHeaders, inputHeaders); + }); + + test('does not change input headers', () { + final inputHeaders = {'foo': 'bar'}; + final resultHeaders = addContentTypeToHeaders( + inputHeaders, HttpPayload.json({'name': 'now the lawn'})); + expect(resultHeaders?['Content-Type'], 'application/json'); + expect(inputHeaders['Content-Type'], isNull); + }); + }); +} From c99ca0c626f2f50a5e85116f0584d1a80417f118 Mon Sep 17 00:00:00 2001 From: Elijah Quartey <Equartey@users.noreply.github.com> Date: Wed, 13 Jul 2022 15:27:11 -0500 Subject: [PATCH 23/33] feat!(api): GraphQL API key auth mode (#1858) * feat(api): GraphQL API key auth mode * BREAKING CHANGE: GraphQL response errors now nullable --- .../types/api/graphql/graphql_response.dart | 9 +- packages/api/amplify_api/LICENSE | 29 ++- .../amplify/amplify_api/MainActivityTest.kt | 16 ++ .../integration_test/graphql_tests.dart | 8 +- .../provision_integration_test_resources.sh | 14 ++ .../lib/src/amplify_api_config.dart | 3 +- .../amplify_authorization_rest_client.dart | 14 +- .../amplify_api/lib/src/api_plugin_impl.dart | 48 +++- .../src/graphql/graphql_response_decoder.dart | 2 +- .../src/graphql/model_mutations_factory.dart | 14 ++ .../lib/src/graphql/send_graphql_request.dart | 57 +++++ .../lib/src/method_channel_api.dart | 17 +- packages/api/amplify_api/lib/src/util.dart | 18 ++ .../test/amplify_api_config_test.dart | 89 +++++++ .../amplify_api/test/dart_graphql_test.dart | 229 ++++++++++++++++++ .../amplify_api/test/graphql_error_test.dart | 2 +- .../query_predicate_graphql_filter_test.dart | 14 ++ .../test_data/fake_amplify_configuration.dart | 14 ++ 18 files changed, 568 insertions(+), 29 deletions(-) create mode 100644 packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart create mode 100644 packages/api/amplify_api/test/amplify_api_config_test.dart create mode 100644 packages/api/amplify_api/test/dart_graphql_test.dart diff --git a/packages/amplify_core/lib/src/types/api/graphql/graphql_response.dart b/packages/amplify_core/lib/src/types/api/graphql/graphql_response.dart index 8a7580fd7d..dc8d2345d6 100644 --- a/packages/amplify_core/lib/src/types/api/graphql/graphql_response.dart +++ b/packages/amplify_core/lib/src/types/api/graphql/graphql_response.dart @@ -22,8 +22,8 @@ class GraphQLResponse<T> { /// This will be `null` if there are any GraphQL errors during execution. final T? data; - /// A list of errors from execution. If no errors, it will be an empty list. - final List<GraphQLResponseError> errors; + /// A list of errors from execution. If no errors, it will be `null`. + final List<GraphQLResponseError>? errors; const GraphQLResponse({ this.data, @@ -36,7 +36,10 @@ class GraphQLResponse<T> { }) { return GraphQLResponse( data: data, - errors: errors ?? const [], + errors: errors, ); } + + // Returns true when errors are present and not empty. + bool get hasErrors => errors != null && errors!.isNotEmpty; } diff --git a/packages/api/amplify_api/LICENSE b/packages/api/amplify_api/LICENSE index 19dc35b243..d645695673 100644 --- a/packages/api/amplify_api/LICENSE +++ b/packages/api/amplify_api/LICENSE @@ -172,4 +172,31 @@ of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. \ No newline at end of file + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/packages/api/amplify_api/example/android/app/src/androidTest/java/com/amazonaws/amplify/amplify_api/MainActivityTest.kt b/packages/api/amplify_api/example/android/app/src/androidTest/java/com/amazonaws/amplify/amplify_api/MainActivityTest.kt index 6f677739be..8b9960a876 100644 --- a/packages/api/amplify_api/example/android/app/src/androidTest/java/com/amazonaws/amplify/amplify_api/MainActivityTest.kt +++ b/packages/api/amplify_api/example/android/app/src/androidTest/java/com/amazonaws/amplify/amplify_api/MainActivityTest.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + */ + package com.amazonaws.amplify.amplify_api_example import androidx.test.rule.ActivityTestRule diff --git a/packages/api/amplify_api/example/integration_test/graphql_tests.dart b/packages/api/amplify_api/example/integration_test/graphql_tests.dart index d632e2ef14..f1a9a42362 100644 --- a/packages/api/amplify_api/example/integration_test/graphql_tests.dart +++ b/packages/api/amplify_api/example/integration_test/graphql_tests.dart @@ -44,7 +44,7 @@ void main() { final req = GraphQLRequest<String>( document: graphQLDocument, variables: <String, String>{'id': id}); final response = await Amplify.API.mutate(request: req).response; - if (response.errors.isNotEmpty) { + if (response.hasErrors) { fail( 'GraphQL error while deleting a blog: ${response.errors.toString()}'); } @@ -561,7 +561,7 @@ void main() { // With stream established, exec callback with stream events. final subscription = await _getEstablishedSubscriptionOperation<T>( subscriptionRequest, (event) { - if (event.errors.isNotEmpty) { + if (event.hasErrors) { fail('subscription errors: ${event.errors}'); } dataCompleter.complete(event); @@ -657,6 +657,8 @@ void main() { expect(postFromEvent?.title, equals(title)); }); - }); + }, + skip: + 'TODO(ragingsquirrel3): re-enable tests once subscriptions are implemented.'); }); } diff --git a/packages/api/amplify_api/example/tool/provision_integration_test_resources.sh b/packages/api/amplify_api/example/tool/provision_integration_test_resources.sh index 072ebabbda..d74e2dc37d 100755 --- a/packages/api/amplify_api/example/tool/provision_integration_test_resources.sh +++ b/packages/api/amplify_api/example/tool/provision_integration_test_resources.sh @@ -1,4 +1,18 @@ #!/bin/bash +# Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + set -e IFS='|' diff --git a/packages/api/amplify_api/lib/src/amplify_api_config.dart b/packages/api/amplify_api/lib/src/amplify_api_config.dart index 960f11bf9b..4d4c21e9fa 100644 --- a/packages/api/amplify_api/lib/src/amplify_api_config.dart +++ b/packages/api/amplify_api/lib/src/amplify_api_config.dart @@ -29,7 +29,8 @@ class EndpointConfig with AWSEquatable<EndpointConfig> { /// Gets the host with environment path prefix from Amplify config and combines /// with [path] and [queryParameters] to return a full [Uri]. - Uri getUri(String path, Map<String, dynamic>? queryParameters) { + Uri getUri({String? path, Map<String, dynamic>? queryParameters}) { + path = path ?? ''; final parsed = Uri.parse(config.endpoint); // Remove leading slashes which are suggested in public documentation. // https://docs.amplify.aws/lib/restapi/getting-started/q/platform/flutter/#make-a-post-request diff --git a/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart index e58885385c..8a2d0678b5 100644 --- a/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart +++ b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart @@ -18,6 +18,8 @@ import 'package:amplify_core/amplify_core.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; +const _xApiKey = 'X-Api-Key'; + /// Implementation of http [http.Client] that authorizes HTTP requests with /// Amplify. @internal @@ -50,8 +52,16 @@ class AmplifyAuthorizationRestClient extends http.BaseClient http.BaseRequest _authorizeRequest(http.BaseRequest request) { if (!request.headers.containsKey(AWSHeaders.authorization) && endpointConfig.authorizationType != APIAuthorizationType.none) { - // ignore: todo - // TODO: Use auth providers from core to transform the request. + // TODO(ragingsquirrel3): Use auth providers from core to transform the request. + final apiKey = endpointConfig.apiKey; + if (endpointConfig.authorizationType == APIAuthorizationType.apiKey) { + if (apiKey == null) { + throw const ApiException( + 'Auth mode is API Key, but no API Key was found in config.'); + } + + request.headers.putIfAbsent(_xApiKey, () => apiKey); + } } return request; } diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index 0926c0a462..a54ad5ee2b 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -26,6 +26,7 @@ import 'package:meta/meta.dart'; import 'amplify_api_config.dart'; import 'amplify_authorization_rest_client.dart'; +import 'graphql/send_graphql_request.dart'; import 'util.dart'; /// {@template amplify_api.amplify_api_dart} @@ -85,6 +86,19 @@ class AmplifyAPIDart extends AmplifyAPI { } } + /// Returns the HTTP client to be used for GraphQL operations. + /// + /// Use [apiName] if there are multiple GraphQL endpoints. + @visibleForTesting + http.Client getGraphQLClient({String? apiName}) { + final endpoint = _apiConfig.getEndpoint( + type: EndpointType.graphQL, + apiName: apiName, + ); + return _clientPool[endpoint.name] ??= AmplifyAuthorizationRestClient( + endpointConfig: endpoint.config, baseClient: _baseHttpClient); + } + /// Returns the HTTP client to be used for REST operations. /// /// Use [apiName] if there are multiple REST endpoints. @@ -100,13 +114,21 @@ class AmplifyAPIDart extends AmplifyAPI { ); } + Uri _getGraphQLUri(String? apiName) { + final endpoint = _apiConfig.getEndpoint( + type: EndpointType.graphQL, + apiName: apiName, + ); + return endpoint.getUri(path: null, queryParameters: null); + } + Uri _getRestUri( String path, String? apiName, Map<String, dynamic>? queryParameters) { final endpoint = _apiConfig.getEndpoint( type: EndpointType.rest, apiName: apiName, ); - return endpoint.getUri(path, queryParameters); + return endpoint.getUri(path: path, queryParameters: queryParameters); } /// NOTE: http does not support request abort https://github.com/dart-lang/http/issues/424. @@ -130,6 +152,30 @@ class AmplifyAPIDart extends AmplifyAPI { _authProviders[authProvider.type] = authProvider; } + // ====== GraphQL ====== + + @override + CancelableOperation<GraphQLResponse<T>> query<T>( + {required GraphQLRequest<T> request}) { + final graphQLClient = getGraphQLClient(apiName: request.apiName); + final uri = _getGraphQLUri(request.apiName); + + final responseFuture = sendGraphQLRequest<T>( + request: request, client: graphQLClient, uri: uri); + return _makeCancelable<GraphQLResponse<T>>(responseFuture); + } + + @override + CancelableOperation<GraphQLResponse<T>> mutate<T>( + {required GraphQLRequest<T> request}) { + final graphQLClient = getGraphQLClient(apiName: request.apiName); + final uri = _getGraphQLUri(request.apiName); + + final responseFuture = sendGraphQLRequest<T>( + request: request, client: graphQLClient, uri: uri); + return _makeCancelable<GraphQLResponse<T>>(responseFuture); + } + // ====== REST ======= @override diff --git a/packages/api/amplify_api/lib/src/graphql/graphql_response_decoder.dart b/packages/api/amplify_api/lib/src/graphql/graphql_response_decoder.dart index 3a66a4cafb..ec77157480 100644 --- a/packages/api/amplify_api/lib/src/graphql/graphql_response_decoder.dart +++ b/packages/api/amplify_api/lib/src/graphql/graphql_response_decoder.dart @@ -34,7 +34,7 @@ class GraphQLResponseDecoder { GraphQLResponse<T> decode<T>( {required GraphQLRequest request, String? data, - required List<GraphQLResponseError> errors}) { + List<GraphQLResponseError>? errors}) { if (data == null) { return GraphQLResponse(data: null, errors: errors); } diff --git a/packages/api/amplify_api/lib/src/graphql/model_mutations_factory.dart b/packages/api/amplify_api/lib/src/graphql/model_mutations_factory.dart index c0c2a4927a..1793cbee49 100644 --- a/packages/api/amplify_api/lib/src/graphql/model_mutations_factory.dart +++ b/packages/api/amplify_api/lib/src/graphql/model_mutations_factory.dart @@ -1,3 +1,17 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + import 'package:amplify_api/src/graphql/graphql_request_factory.dart'; import 'package:amplify_core/amplify_core.dart'; diff --git a/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart b/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart new file mode 100644 index 0000000000..6eab7deadd --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart @@ -0,0 +1,57 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +import 'dart:convert'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +import '../util.dart'; +import 'graphql_response_decoder.dart'; + +/// Converts the [GraphQLRequest] to an HTTP POST request and sends with ///[client]. +@internal +Future<GraphQLResponse<T>> sendGraphQLRequest<T>({ + required GraphQLRequest<T> request, + required http.Client client, + required Uri uri, +}) async { + try { + final body = {'variables': request.variables, 'query': request.document}; + final graphQLResponse = await client.post(uri, body: json.encode(body)); + + final responseBody = json.decode(graphQLResponse.body); + + if (responseBody is! Map<String, dynamic>) { + throw ApiException( + 'unable to parse GraphQLResponse from server response which was not a JSON object.', + underlyingException: graphQLResponse.body); + } + + final responseData = responseBody['data']; + // Preserve `null`. json.encode(null) returns "null" not `null` + final responseDataJson = + responseData != null ? json.encode(responseData) : null; + + final errors = deserializeGraphQLResponseErrors(responseBody); + + return GraphQLResponseDecoder.instance + .decode<T>(request: request, data: responseDataJson, errors: errors); + } on Exception catch (e) { + throw ApiException('unable to send GraphQLRequest to client.', + underlyingException: e.toString()); + } +} diff --git a/packages/api/amplify_api/lib/src/method_channel_api.dart b/packages/api/amplify_api/lib/src/method_channel_api.dart index c0285a6305..45f5eb862f 100644 --- a/packages/api/amplify_api/lib/src/method_channel_api.dart +++ b/packages/api/amplify_api/lib/src/method_channel_api.dart @@ -207,7 +207,7 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { AmplifyExceptionMessages.nullReturnedFromMethodChannel, ); } - final errors = _deserializeGraphQLResponseErrors(result); + final errors = deserializeGraphQLResponseErrors(result); GraphQLResponse<T> response = GraphQLResponseDecoder.instance.decode<T>( request: request, data: result['data'] as String?, errors: errors); @@ -466,19 +466,4 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { ); } } - - List<GraphQLResponseError> _deserializeGraphQLResponseErrors( - Map<String, dynamic> response, - ) { - final errors = response['errors'] as List?; - if (errors == null || errors.isEmpty) { - return const []; - } - return errors - .cast<Map>() - .map((message) => GraphQLResponseError.fromJson( - message.cast<String, dynamic>(), - )) - .toList(); - } } diff --git a/packages/api/amplify_api/lib/src/util.dart b/packages/api/amplify_api/lib/src/util.dart index d91d58ce48..2d28b59afc 100644 --- a/packages/api/amplify_api/lib/src/util.dart +++ b/packages/api/amplify_api/lib/src/util.dart @@ -30,3 +30,21 @@ Map<String, String>? addContentTypeToHeaders( modifiedHeaders.putIfAbsent(AWSHeaders.contentType, () => contentType); return modifiedHeaders; } + +/// Grabs errors from GraphQL Response. Is used in method channel and Dart first code. +/// TODO(Equartey): Move to Dart first code when method channel GraphQL implementation is removed. +@internal +List<GraphQLResponseError>? deserializeGraphQLResponseErrors( + Map<String, dynamic> response, +) { + final errors = response['errors'] as List?; + if (errors == null || errors.isEmpty) { + return null; + } + return errors + .cast<Map>() + .map((message) => GraphQLResponseError.fromJson( + message.cast<String, dynamic>(), + )) + .toList(); +} diff --git a/packages/api/amplify_api/test/amplify_api_config_test.dart b/packages/api/amplify_api/test/amplify_api_config_test.dart new file mode 100644 index 0000000000..5168adfa04 --- /dev/null +++ b/packages/api/amplify_api/test/amplify_api_config_test.dart @@ -0,0 +1,89 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:convert'; + +import 'package:amplify_api/src/amplify_api_config.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'test_data/fake_amplify_configuration.dart'; + +void main() { + late EndpointConfig endpointConfig; + + group('GraphQL Config', () { + const endpointType = EndpointType.graphQL; + const endpoint = + 'https://abc123.appsync-api.us-east-1.amazonaws.com/graphql'; + const region = 'us-east-1'; + const authorizationType = APIAuthorizationType.apiKey; + const apiKey = 'abc-123'; + + setUpAll(() async { + const config = AWSApiConfig( + endpointType: endpointType, + endpoint: endpoint, + region: region, + authorizationType: authorizationType, + apiKey: apiKey); + + endpointConfig = const EndpointConfig('GraphQL', config); + }); + + test('should return valid URI with null params', () async { + final uri = endpointConfig.getUri(); + final expected = Uri.parse('$endpoint/'); + + expect(uri, equals(expected)); + }); + }); + + group('REST Config', () { + const endpointType = EndpointType.rest; + const endpoint = 'https://abc123.appsync-api.us-east-1.amazonaws.com/test'; + const region = 'us-east-1'; + const authorizationType = APIAuthorizationType.iam; + + setUpAll(() async { + const config = AWSApiConfig( + endpointType: endpointType, + endpoint: endpoint, + region: region, + authorizationType: authorizationType); + + endpointConfig = const EndpointConfig('REST', config); + }); + + test('should return valid URI with params', () async { + final path = 'path/to/nowhere'; + final params = {'foo': 'bar', 'bar': 'baz'}; + final uri = endpointConfig.getUri(path: path, queryParameters: params); + + final expected = Uri.parse('$endpoint/$path?foo=bar&bar=baz'); + + expect(uri, equals(expected)); + }); + + test('should handle a leading slash', () async { + final path = '/path/to/nowhere'; + final params = {'foo': 'bar', 'bar': 'baz'}; + final uri = endpointConfig.getUri(path: path, queryParameters: params); + + final expected = Uri.parse('$endpoint$path?foo=bar&bar=baz'); + + expect(uri, equals(expected)); + }); + }); +} diff --git a/packages/api/amplify_api/test/dart_graphql_test.dart b/packages/api/amplify_api/test/dart_graphql_test.dart new file mode 100644 index 0000000000..bedd0092f2 --- /dev/null +++ b/packages/api/amplify_api/test/dart_graphql_test.dart @@ -0,0 +1,229 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:convert'; + +import 'package:amplify_api/amplify_api.dart'; +import 'package:amplify_api/src/api_plugin_impl.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:amplify_test/test_models/ModelProvider.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'test_data/fake_amplify_configuration.dart'; + +final _deepEquals = const DeepCollectionEquality().equals; + +// Success Mocks +const _expectedQuerySuccessResponseBody = { + 'data': { + 'listBlogs': { + 'items': [ + { + 'id': 'TEST_ID', + 'name': 'Test App Blog', + 'createdAt': '2022-06-28T17:36:52.460Z' + } + ] + } + } +}; + +final _modelQueryId = uuid(); +final _expectedModelQueryResult = { + 'data': { + 'getBlog': { + 'createdAt': '2021-07-21T22:23:33.707Z', + 'id': _modelQueryId, + 'name': 'Test App Blog' + } + } +}; +const _expectedMutateSuccessResponseBody = { + 'data': { + 'createBlog': { + 'id': 'TEST_ID', + 'name': 'Test App Blog', + 'createdAt': '2022-07-06T18:42:26.126Z' + } + } +}; + +// Error Mocks +const _errorMessage = 'Unable to parse GraphQL query.'; +const _errorLocations = [ + {'line': 2, 'column': 3}, + {'line': 4, 'column': 5} +]; +const _errorPath = ['a', 1, 'b']; +const _errorExtensions = { + 'a': 'blah', + 'b': {'c': 'd'} +}; +const _expectedErrorResponseBody = { + 'data': null, + 'errors': [ + { + 'message': _errorMessage, + 'locations': _errorLocations, + 'path': _errorPath, + 'extensions': _errorExtensions, + }, + ] +}; + +class MockAmplifyAPI extends AmplifyAPIDart { + MockAmplifyAPI({ + ModelProviderInterface? modelProvider, + }) : super(modelProvider: modelProvider); + + @override + http.Client getGraphQLClient({String? apiName}) => + MockClient((request) async { + if (request.body.contains('getBlog')) { + return http.Response(json.encode(_expectedModelQueryResult), 200); + } + if (request.body.contains('TestMutate')) { + return http.Response( + json.encode(_expectedMutateSuccessResponseBody), 400); + } + if (request.body.contains('TestError')) { + return http.Response(json.encode(_expectedErrorResponseBody), 400); + } + + return http.Response( + json.encode(_expectedQuerySuccessResponseBody), 200); + }); +} + +void main() { + setUpAll(() async { + await Amplify.addPlugin(MockAmplifyAPI( + modelProvider: ModelProvider.instance, + )); + await Amplify.configure(amplifyconfig); + }); + group('Vanilla GraphQL', () { + test('Query returns proper response.data', () async { + String graphQLDocument = ''' query TestQuery { + listBlogs { + items { + id + name + createdAt + } + } + } '''; + final req = GraphQLRequest(document: graphQLDocument, variables: {}); + + final operation = Amplify.API.query(request: req); + final res = await operation.value; + + final expected = json.encode(_expectedQuerySuccessResponseBody['data']); + + expect(res.data, equals(expected)); + expect(res.errors, equals(null)); + }); + + test('Mutate returns proper response.data', () async { + String graphQLDocument = ''' mutation TestMutate(\$name: String!) { + createBlog(input: {name: \$name}) { + id + name + createdAt + } + } '''; + final graphQLVariables = {'name': 'Test Blog 1'}; + final req = GraphQLRequest( + document: graphQLDocument, variables: graphQLVariables); + + final operation = Amplify.API.mutate(request: req); + final res = await operation.value; + + final expected = json.encode(_expectedMutateSuccessResponseBody['data']); + + expect(res.data, equals(expected)); + expect(res.errors, equals(null)); + }); + }); + group('Model Helpers', () { + const blogSelectionSet = + 'id name createdAt file { bucket region key meta { name } } files { bucket region key meta { name } } updatedAt'; + + test('Query returns proper response.data for Models', () async { + const expectedDoc = + 'query getBlog(\$id: ID!) { getBlog(id: \$id) { $blogSelectionSet } }'; + const decodePath = 'getBlog'; + + GraphQLRequest<Blog> req = + ModelQueries.get<Blog>(Blog.classType, _modelQueryId); + + final operation = Amplify.API.query(request: req); + final res = await operation.value; + + // request asserts + expect(req.document, expectedDoc); + expect(_deepEquals(req.variables, {'id': _modelQueryId}), isTrue); + expect(req.modelType, Blog.classType); + expect(req.decodePath, decodePath); + // response asserts + expect(res.data, isA<Blog>()); + expect(res.data?.id, _modelQueryId); + expect(res.errors, equals(null)); + }); + }); + + group('Error Handling', () { + test('response errors are decoded', () async { + String graphQLDocument = ''' TestError '''; + final req = GraphQLRequest(document: graphQLDocument, variables: {}); + + final operation = Amplify.API.query(request: req); + final res = await operation.value; + + const errorExpected = GraphQLResponseError( + message: _errorMessage, + locations: [ + GraphQLResponseErrorLocation(2, 3), + GraphQLResponseErrorLocation(4, 5), + ], + path: <dynamic>[..._errorPath], + extensions: <String, dynamic>{..._errorExtensions}, + ); + + expect(res.data, equals(null)); + expect(res.errors?.single, equals(errorExpected)); + }); + + test('canceled query request should never resolve', () async { + final req = GraphQLRequest(document: '', variables: {}); + final operation = Amplify.API.query(request: req); + operation.cancel(); + operation.then((p0) => fail('Request should have been cancelled.')); + await operation.valueOrCancellation(); + expect(operation.isCanceled, isTrue); + }); + + test('canceled mutation request should never resolve', () async { + final req = GraphQLRequest(document: '', variables: {}); + final operation = Amplify.API.mutate(request: req); + operation.cancel(); + operation.then((p0) => fail('Request should have been cancelled.')); + await operation.valueOrCancellation(); + expect(operation.isCanceled, isTrue); + }); + }); +} diff --git a/packages/api/amplify_api/test/graphql_error_test.dart b/packages/api/amplify_api/test/graphql_error_test.dart index ee6588691a..32752299ee 100644 --- a/packages/api/amplify_api/test/graphql_error_test.dart +++ b/packages/api/amplify_api/test/graphql_error_test.dart @@ -68,6 +68,6 @@ void main() { .response; expect(resp.data, equals(null)); - expect(resp.errors.single, equals(expected)); + expect(resp.errors?.single, equals(expected)); }); } diff --git a/packages/api/amplify_api/test/query_predicate_graphql_filter_test.dart b/packages/api/amplify_api/test/query_predicate_graphql_filter_test.dart index acf8cf18a8..850fd5e1a4 100644 --- a/packages/api/amplify_api/test/query_predicate_graphql_filter_test.dart +++ b/packages/api/amplify_api/test/query_predicate_graphql_filter_test.dart @@ -1,3 +1,17 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + import 'package:amplify_api/src/graphql/graphql_request_factory.dart'; import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:amplify_flutter/src/amplify_impl.dart'; diff --git a/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart b/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart index 7b8fd53be0..0b3c0dae01 100644 --- a/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart +++ b/packages/api/amplify_api/test/test_data/fake_amplify_configuration.dart @@ -1,3 +1,17 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + const amplifyconfig = '''{ "UserAgent": "aws-amplify-cli/2.0", "Version": "1.0", From 98891d77397517ca75c9846b4a1603afb6fda10c Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Tue, 19 Jul 2022 08:42:49 -0800 Subject: [PATCH 24/33] feat!(core,auth): auth providers definition and CognitoIamAuthProvider registers in Auth (#1851) --- .../amplify_flutter/lib/src/hybrid_impl.dart | 3 +- packages/amplify_core/lib/amplify_core.dart | 3 + .../lib/src/amplify_class_impl.dart | 8 +- .../src/plugin/amplify_plugin_interface.dart | 5 +- .../api/auth/api_authorization_type.dart | 18 ++- .../types/common/amplify_auth_provider.dart | 79 +++++++++++ packages/amplify_core/pubspec.yaml | 2 +- .../test/amplify_auth_provider_test.dart | 132 ++++++++++++++++++ .../amplify_api/lib/src/api_plugin_impl.dart | 5 +- .../lib/src/auth_plugin_impl.dart | 14 +- .../src/util/cognito_iam_auth_provider.dart | 83 +++++++++++ .../test/plugin/auth_providers_test.dart | 112 +++++++++++++++ .../test/plugin/delete_user_test.dart | 17 ++- .../test/plugin/sign_out_test.dart | 52 +++++-- 14 files changed, 507 insertions(+), 26 deletions(-) create mode 100644 packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart create mode 100644 packages/amplify_core/test/amplify_auth_provider_test.dart create mode 100644 packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_iam_auth_provider.dart create mode 100644 packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart diff --git a/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart b/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart index 5eb3f1257e..8c166f03f6 100644 --- a/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart +++ b/packages/amplify/amplify_flutter/lib/src/hybrid_impl.dart @@ -36,7 +36,8 @@ class AmplifyHybridImpl extends AmplifyClassImpl { [ ...API.plugins, ...Auth.plugins, - ].map((p) => p.configure(config: amplifyConfig)), + ].map((p) => p.configure( + config: amplifyConfig, authProviderRepo: authProviderRepo)), eagerError: true, ); await _methodChannelAmplify.configurePlatform(config); diff --git a/packages/amplify_core/lib/amplify_core.dart b/packages/amplify_core/lib/amplify_core.dart index f8cb76f2bb..8086bd78fe 100644 --- a/packages/amplify_core/lib/amplify_core.dart +++ b/packages/amplify_core/lib/amplify_core.dart @@ -74,6 +74,9 @@ export 'src/types/api/api_types.dart'; /// Auth export 'src/types/auth/auth_types.dart'; +/// Auth providers +export 'src/types/common/amplify_auth_provider.dart'; + /// Datastore export 'src/types/datastore/datastore_types.dart' hide DateTimeParse; diff --git a/packages/amplify_core/lib/src/amplify_class_impl.dart b/packages/amplify_core/lib/src/amplify_class_impl.dart index d802d4a69d..00c9cba346 100644 --- a/packages/amplify_core/lib/src/amplify_class_impl.dart +++ b/packages/amplify_core/lib/src/amplify_class_impl.dart @@ -24,6 +24,11 @@ import 'package:meta/meta.dart'; /// {@endtemplate} @internal class AmplifyClassImpl extends AmplifyClass { + /// Share AmplifyAuthProviders with plugins. + @protected + final AmplifyAuthProviderRepository authProviderRepo = + AmplifyAuthProviderRepository(); + /// {@macro amplify_flutter.amplify_class_impl} AmplifyClassImpl() : super.protected(); @@ -57,7 +62,8 @@ class AmplifyClassImpl extends AmplifyClass { ...Auth.plugins, ...DataStore.plugins, ...Storage.plugins, - ].map((p) => p.configure(config: amplifyConfig)), + ].map((p) => p.configure( + config: amplifyConfig, authProviderRepo: authProviderRepo)), eagerError: true, ); } diff --git a/packages/amplify_core/lib/src/plugin/amplify_plugin_interface.dart b/packages/amplify_core/lib/src/plugin/amplify_plugin_interface.dart index 821c6fe38e..4ca5f7c2a1 100644 --- a/packages/amplify_core/lib/src/plugin/amplify_plugin_interface.dart +++ b/packages/amplify_core/lib/src/plugin/amplify_plugin_interface.dart @@ -30,7 +30,10 @@ abstract class AmplifyPluginInterface { Future<void> addPlugin() async {} /// Configures the plugin using the registered [config]. - Future<void> configure({AmplifyConfig? config}) async {} + Future<void> configure({ + AmplifyConfig? config, + required AmplifyAuthProviderRepository authProviderRepo, + }) async {} /// Resets the plugin by removing all traces of it from the device. @visibleForTesting diff --git a/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart b/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart index e81ef856f4..f15da13b9f 100644 --- a/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart +++ b/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart @@ -13,6 +13,7 @@ * permissions and limitations under the License. */ +import 'package:amplify_core/src/types/common/amplify_auth_provider.dart'; import 'package:collection/collection.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -24,17 +25,17 @@ part 'api_authorization_type.g.dart'; /// See also: /// - [AppSync Security](https://docs.aws.amazon.com/appsync/latest/devguide/security.html) @JsonEnum(alwaysCreate: true) -enum APIAuthorizationType { +enum APIAuthorizationType<T extends AmplifyAuthProvider> { /// For public APIs. @JsonValue('NONE') - none, + none(AmplifyAuthProviderToken<AmplifyAuthProvider>()), /// A hardcoded key which can provide throttling for an unauthenticated API. /// /// See also: /// - [API Key Authorization](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#api-key-authorization) @JsonValue('API_KEY') - apiKey, + apiKey(AmplifyAuthProviderToken<AmplifyAuthProvider>()), /// Use an IAM access/secret key credential pair to authorize access to an API. /// @@ -42,7 +43,7 @@ enum APIAuthorizationType { /// - [IAM Authorization](https://docs.aws.amazon.com/appsync/latest/devguide/security.html#aws-iam-authorization) /// - [IAM Introduction](https://docs.aws.amazon.com/IAM/latest/UserGuide/introduction.html) @JsonValue('AWS_IAM') - iam, + iam(AmplifyAuthProviderToken<AWSIamAmplifyAuthProvider>()), /// OpenID Connect is a simple identity layer on top of OAuth2.0. /// @@ -50,21 +51,24 @@ enum APIAuthorizationType { /// - [OpenID Connect Authorization](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#openid-connect-authorization) /// - [OpenID Connect Specification](https://openid.net/specs/openid-connect-core-1_0.html) @JsonValue('OPENID_CONNECT') - oidc, + oidc(AmplifyAuthProviderToken<TokenAmplifyAuthProvider>()), /// Control access to date by putting users into different permissions pools. /// /// See also: /// - [Amazon Cognito User Pools](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#amazon-cognito-user-pools-authorization) @JsonValue('AMAZON_COGNITO_USER_POOLS') - userPools, + userPools(AmplifyAuthProviderToken<TokenAmplifyAuthProvider>()), /// Control access by calling a lambda function. /// /// See also: /// - [Introducing Lambda authorization for AWS AppSync GraphQL APIs](https://aws.amazon.com/blogs/mobile/appsync-lambda-auth/) @JsonValue('AWS_LAMBDA') - function + function(AmplifyAuthProviderToken<TokenAmplifyAuthProvider>()); + + const APIAuthorizationType(this.authProviderToken); + final AmplifyAuthProviderToken<T> authProviderToken; } /// Helper methods for [APIAuthorizationType]. diff --git a/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart b/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart new file mode 100644 index 0000000000..30c00ff053 --- /dev/null +++ b/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart @@ -0,0 +1,79 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +import 'package:amplify_core/amplify_core.dart'; +import 'package:aws_signature_v4/aws_signature_v4.dart'; + +/// An identifier to use as a key in an [AmplifyAuthProviderRepository] so that +/// a retrieved auth provider can be typed more accurately. +class AmplifyAuthProviderToken<T extends AmplifyAuthProvider> extends Token<T> { + const AmplifyAuthProviderToken(); +} + +abstract class AuthProviderOptions { + const AuthProviderOptions(); +} + +/// Options required by IAM to sign any given request at runtime. +class IamAuthProviderOptions extends AuthProviderOptions { + final String region; + final AWSService service; + + const IamAuthProviderOptions({required this.region, required this.service}); +} + +abstract class AmplifyAuthProvider { + Future<AWSBaseHttpRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }); +} + +abstract class AWSIamAmplifyAuthProvider extends AmplifyAuthProvider + implements AWSCredentialsProvider { + @override + Future<AWSSignedRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant IamAuthProviderOptions options, + }); +} + +abstract class TokenAmplifyAuthProvider extends AmplifyAuthProvider { + Future<String> getLatestAuthToken(); + + @override + Future<AWSBaseHttpRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }) async { + final token = await getLatestAuthToken(); + request.headers.putIfAbsent(AWSHeaders.authorization, () => token); + return request; + } +} + +class AmplifyAuthProviderRepository { + final Map<AmplifyAuthProviderToken, AmplifyAuthProvider> _authProviders = {}; + + T? getAuthProvider<T extends AmplifyAuthProvider>( + AmplifyAuthProviderToken<T> token) { + return _authProviders[token] as T?; + } + + void registerAuthProvider<T extends AmplifyAuthProvider>( + AmplifyAuthProviderToken<T> token, AmplifyAuthProvider authProvider) { + _authProviders[token] = authProvider; + } +} diff --git a/packages/amplify_core/pubspec.yaml b/packages/amplify_core/pubspec.yaml index 60d959babd..f044a78d7e 100644 --- a/packages/amplify_core/pubspec.yaml +++ b/packages/amplify_core/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: aws_common: ^0.2.0 aws_signature_v4: ^0.2.0 collection: ^1.15.0 - http: ^0.13.0 + http: ^0.13.4 intl: ^0.17.0 json_annotation: ^4.6.0 logging: ^1.0.0 diff --git a/packages/amplify_core/test/amplify_auth_provider_test.dart b/packages/amplify_core/test/amplify_auth_provider_test.dart new file mode 100644 index 0000000000..08a0e06e4d --- /dev/null +++ b/packages/amplify_core/test/amplify_auth_provider_test.dart @@ -0,0 +1,132 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +import 'package:amplify_core/amplify_core.dart'; +import 'package:aws_signature_v4/aws_signature_v4.dart'; +import 'package:test/test.dart'; + +const _testAuthKey = 'TestAuthKey'; +const _testToken = 'abc123-fake-token'; + +AWSHttpRequest _generateTestRequest() { + return AWSHttpRequest( + method: AWSHttpMethod.get, + uri: Uri.parse('https://www.amazon.com'), + ); +} + +class TestAuthProvider extends AmplifyAuthProvider { + @override + Future<AWSBaseHttpRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }) async { + request.headers.putIfAbsent(_testAuthKey, () => 'foo'); + return request; + } +} + +class SecondTestAuthProvider extends AmplifyAuthProvider { + @override + Future<AWSBaseHttpRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }) async { + request.headers.putIfAbsent(_testAuthKey, () => 'bar'); + return request; + } +} + +class TestAWSCredentialsAuthProvider extends AWSIamAmplifyAuthProvider { + @override + Future<AWSCredentials> retrieve() async { + return const AWSCredentials( + 'fake-access-key-123', 'fake-secret-access-key-456'); + } + + @override + Future<AWSSignedRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant IamAuthProviderOptions? options, + }) async { + request.headers.putIfAbsent(_testAuthKey, () => 'foo'); + return request as AWSSignedRequest; + } +} + +class TestTokenProvider extends TokenAmplifyAuthProvider { + @override + Future<String> getLatestAuthToken() async { + return _testToken; + } +} + +void main() { + final authProvider = TestAuthProvider(); + + group('AmplifyAuthProvider', () { + test('can authorize an HTTP request', () async { + final authorizedRequest = + await authProvider.authorizeRequest(_generateTestRequest()); + expect(authorizedRequest.headers[_testAuthKey], 'foo'); + }); + }); + + group('TokenAmplifyAuthProvider', () { + test('will assign the token to the "Authorization" header', () async { + final tokenAuthProvider = TestTokenProvider(); + final authorizedRequest = + await tokenAuthProvider.authorizeRequest(_generateTestRequest()); + expect(authorizedRequest.headers[AWSHeaders.authorization], _testToken); + }); + }); + + group('AmplifyAuthProviderRepository', () { + test('can register a valid auth provider and use to retrieve', () async { + final authRepo = AmplifyAuthProviderRepository(); + + const providerKey = AmplifyAuthProviderToken(); + authRepo.registerAuthProvider(providerKey, authProvider); + final actualAuthProvider = authRepo.getAuthProvider(providerKey); + final authorizedRequest = + await actualAuthProvider!.authorizeRequest(_generateTestRequest()); + expect(authorizedRequest.headers[_testAuthKey], 'foo'); + }); + + test('will correctly type the retrieved auth provider', () async { + final authRepo = AmplifyAuthProviderRepository(); + + final credentialAuthProvider = TestAWSCredentialsAuthProvider(); + const providerKey = AmplifyAuthProviderToken<AWSIamAmplifyAuthProvider>(); + authRepo.registerAuthProvider(providerKey, credentialAuthProvider); + AWSIamAmplifyAuthProvider? actualAuthProvider = + authRepo.getAuthProvider(providerKey); + expect(actualAuthProvider, isA<AWSIamAmplifyAuthProvider>()); + }); + + test('will overwrite previous provider in same key', () async { + final authRepo = AmplifyAuthProviderRepository(); + + const providerKey = AmplifyAuthProviderToken(); + authRepo.registerAuthProvider(providerKey, authProvider); + authRepo.registerAuthProvider(providerKey, SecondTestAuthProvider()); + final actualAuthProvider = authRepo.getAuthProvider(providerKey); + + final authorizedRequest = + await actualAuthProvider!.authorizeRequest(_generateTestRequest()); + expect(authorizedRequest.headers[_testAuthKey], 'bar'); + }); + }); +} diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index a54ad5ee2b..a5dfd58ce6 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -54,7 +54,10 @@ class AmplifyAPIDart extends AmplifyAPI { } @override - Future<void> configure({AmplifyConfig? config}) async { + Future<void> configure({ + AmplifyConfig? config, + required AmplifyAuthProviderRepository authProviderRepo, + }) async { final apiConfig = config?.api?.awsPlugin; if (apiConfig == null) { throw const ApiException('No AWS API config found', diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart index 08a5f53f0e..1db9c30481 100644 --- a/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart @@ -51,6 +51,7 @@ import 'package:amplify_auth_cognito_dart/src/sdk/cognito_identity_provider.dart VerifyUserAttributeRequest; import 'package:amplify_auth_cognito_dart/src/sdk/sdk_bridge.dart'; import 'package:amplify_auth_cognito_dart/src/state/state.dart'; +import 'package:amplify_auth_cognito_dart/src/util/cognito_iam_auth_provider.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:amplify_secure_storage_dart/amplify_secure_storage_dart.dart'; import 'package:built_collection/built_collection.dart'; @@ -174,10 +175,21 @@ class AmplifyAuthCognitoDart extends AuthPluginInterface } @override - Future<void> configure({AmplifyConfig? config}) async { + Future<void> configure({ + AmplifyConfig? config, + required AmplifyAuthProviderRepository authProviderRepo, + }) async { if (config == null) { throw const AuthException('No Cognito plugin config detected'); } + + // Register auth providers to provide auth functionality to other plugins + // without requiring other plugins to call `Amplify.Auth...` directly. + authProviderRepo.registerAuthProvider( + APIAuthorizationType.iam.authProviderToken, + CognitoIamAuthProvider(), + ); + if (_stateMachine.getOrCreate(AuthStateMachine.type).currentState.type != AuthStateType.notConfigured) { throw const AmplifyAlreadyConfiguredException( diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_iam_auth_provider.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_iam_auth_provider.dart new file mode 100644 index 0000000000..b50be60932 --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_iam_auth_provider.dart @@ -0,0 +1,83 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:async'; + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:aws_signature_v4/aws_signature_v4.dart'; +import 'package:meta/meta.dart'; + +/// [AmplifyAuthProvider] implementation that signs a request using AWS credentials +/// from `Amplify.Auth.fetchAuthSession()` or allows getting credentials directly. +@internal +class CognitoIamAuthProvider extends AWSIamAmplifyAuthProvider { + /// AWS credentials from Auth category. + @override + Future<AWSCredentials> retrieve() async { + final authSession = await Amplify.Auth.fetchAuthSession( + options: const CognitoSessionOptions(getAWSCredentials: true), + ) as CognitoAuthSession; + final credentials = authSession.credentials; + if (credentials == null) { + throw const InvalidCredentialsException( + 'Unable to authorize request with IAM. No AWS credentials.', + ); + } + return credentials; + } + + /// Signs request with AWSSigV4Signer and AWS credentials from `.getCredentials()`. + @override + Future<AWSSignedRequest> authorizeRequest( + AWSBaseHttpRequest request, { + IamAuthProviderOptions? options, + }) async { + if (options == null) { + throw const AuthException( + 'Unable to authorize request with IAM. No region or service provided.', + ); + } + + return _signRequest( + request, + region: options.region, + service: options.service, + credentials: await retrieve(), + ); + } + + /// Takes input [request] as canonical request and generates a signed version. + Future<AWSSignedRequest> _signRequest( + AWSBaseHttpRequest request, { + required String region, + required AWSService service, + required AWSCredentials credentials, + }) { + // Create signer helper params. + final signer = AWSSigV4Signer( + credentialsProvider: AWSCredentialsProvider(credentials), + ); + final scope = AWSCredentialScope( + region: region, + service: service, + ); + + // Finally, create and sign canonical request. + return signer.sign( + request, + credentialScope: scope, + ); + } +} diff --git a/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart b/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart new file mode 100644 index 0000000000..acb126fa66 --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart @@ -0,0 +1,112 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +import 'dart:async'; + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart' + hide InternalErrorException; +import 'package:amplify_auth_cognito_dart/src/util/cognito_iam_auth_provider.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:test/test.dart'; + +import '../common/mock_config.dart'; +import '../common/mock_secure_storage.dart'; + +AWSHttpRequest _generateTestRequest() { + return AWSHttpRequest( + method: AWSHttpMethod.get, + uri: Uri.parse('https://www.amazon.com'), + ); +} + +/// Returns dummy AWS credentials. +class TestAmplifyAuth extends AmplifyAuthCognitoDart { + @override + Future<AuthSession> fetchAuthSession({ + required AuthSessionRequest request, + }) async { + return const CognitoAuthSession( + isSignedIn: true, + credentials: AWSCredentials('fakeKeyId', 'fakeSecret'), + ); + } +} + +void main() { + group( + 'AmplifyAuthCognitoDart plugin registers auth providers during configuration', + () { + late AmplifyAuthCognitoDart plugin; + + setUp(() async { + plugin = AmplifyAuthCognitoDart(credentialStorage: MockSecureStorage()); + }); + + test('registers CognitoIamAuthProvider', () async { + final testAuthRepo = AmplifyAuthProviderRepository(); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + final authProvider = testAuthRepo.getAuthProvider( + APIAuthorizationType.iam.authProviderToken, + ); + expect(authProvider, isA<CognitoIamAuthProvider>()); + }); + }); + + group('CognitoIamAuthProvider', () { + setUpAll(() async { + await Amplify.addPlugin(TestAmplifyAuth()); + }); + + test('gets AWS credentials from Amplify.Auth.fetchAuthSession', () async { + final authProvider = CognitoIamAuthProvider(); + final credentials = await authProvider.retrieve(); + expect(credentials.accessKeyId, isA<String>()); + expect(credentials.secretAccessKey, isA<String>()); + }); + + test('signs a request when calling authorizeRequest', () async { + final authProvider = CognitoIamAuthProvider(); + final authorizedRequest = await authProvider.authorizeRequest( + _generateTestRequest(), + options: const IamAuthProviderOptions( + region: 'us-east-1', + service: AWSService.appSync, + ), + ); + // Note: not intended to be complete test of sigv4 algorithm. + expect(authorizedRequest.headers[AWSHeaders.authorization], isNotEmpty); + const userAgentHeader = + zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; + expect( + authorizedRequest.headers[AWSHeaders.host], + isNotEmpty, + skip: zIsWeb, + ); + expect( + authorizedRequest.headers[userAgentHeader], + contains('aws-sigv4'), + ); + }); + + test('throws when no options provided', () async { + final authProvider = CognitoIamAuthProvider(); + await expectLater( + authProvider.authorizeRequest(_generateTestRequest()), + throwsA(isA<AuthException>()), + ); + }); + }); +} diff --git a/packages/auth/amplify_auth_cognito_test/test/plugin/delete_user_test.dart b/packages/auth/amplify_auth_cognito_test/test/plugin/delete_user_test.dart index b589b6b110..15e08de206 100644 --- a/packages/auth/amplify_auth_cognito_test/test/plugin/delete_user_test.dart +++ b/packages/auth/amplify_auth_cognito_test/test/plugin/delete_user_test.dart @@ -58,6 +58,8 @@ void main() { late StreamController<AuthHubEvent> hubEventsController; late Stream<AuthHubEvent> hubEvents; + final testAuthRepo = AmplifyAuthProviderRepository(); + final userDeletedEvent = isA<AuthHubEvent>().having( (event) => event.type, 'type', @@ -83,7 +85,10 @@ void main() { group('deleteUser', () { test('throws when signed out', () async { - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); await expectLater(plugin.deleteUser(), throwsSignedOutException); expect(hubEvents, neverEmits(userDeletedEvent)); @@ -96,7 +101,10 @@ void main() { userPoolKeys: userPoolKeys, identityPoolKeys: identityPoolKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient(() async {}); stateMachine.addInstance<CognitoIdentityProviderClient>(mockIdp); @@ -113,7 +121,10 @@ void main() { userPoolKeys: userPoolKeys, identityPoolKeys: identityPoolKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient(() async { throw InternalErrorException(); diff --git a/packages/auth/amplify_auth_cognito_test/test/plugin/sign_out_test.dart b/packages/auth/amplify_auth_cognito_test/test/plugin/sign_out_test.dart index 6c9f3fe3a2..fe14fc98be 100644 --- a/packages/auth/amplify_auth_cognito_test/test/plugin/sign_out_test.dart +++ b/packages/auth/amplify_auth_cognito_test/test/plugin/sign_out_test.dart @@ -69,6 +69,8 @@ void main() { late StreamController<AuthHubEvent> hubEventsController; late Stream<AuthHubEvent> hubEvents; + final testAuthRepo = AmplifyAuthProviderRepository(); + final emitsSignOutEvent = emitsThrough( isA<AuthHubEvent>().having( (event) => event.type, @@ -112,14 +114,20 @@ void main() { group('signOut', () { test('completes when already signed out', () async { - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); expect(plugin.signOut(), completes); expect(hubEvents, emitsSignOutEvent); }); test('does not clear AWS creds when already signed out', () async { seedStorage(secureStorage, identityPoolKeys: identityPoolKeys); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); await expectLater(plugin.signOut(), completes); expect(hubEvents, emitsSignOutEvent); @@ -144,7 +152,10 @@ void main() { userPoolKeys: userPoolKeys, identityPoolKeys: identityPoolKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient( globalSignOut: () async => GlobalSignOutResponse(), @@ -165,7 +176,10 @@ void main() { userPoolKeys: userPoolKeys, identityPoolKeys: identityPoolKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient( globalSignOut: @@ -194,7 +208,10 @@ void main() { userPoolKeys: userPoolKeys, identityPoolKeys: identityPoolKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient( globalSignOut: () async => GlobalSignOutResponse(), @@ -217,7 +234,10 @@ void main() { test('can sign out in user pool-only mode', () async { seedStorage(secureStorage, userPoolKeys: userPoolKeys); - await plugin.configure(config: userPoolOnlyConfig); + await plugin.configure( + config: userPoolOnlyConfig, + authProviderRepo: testAuthRepo, + ); expect(plugin.signOut(), completes); }); @@ -229,7 +249,10 @@ void main() { identityPoolKeys: identityPoolKeys, hostedUiKeys: hostedUiKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient( globalSignOut: () async => GlobalSignOutResponse(), @@ -250,7 +273,10 @@ void main() { identityPoolKeys: identityPoolKeys, hostedUiKeys: hostedUiKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient( globalSignOut: @@ -279,7 +305,10 @@ void main() { identityPoolKeys: identityPoolKeys, hostedUiKeys: hostedUiKeys, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); final mockIdp = MockCognitoIdpClient( globalSignOut: () async => GlobalSignOutResponse(), @@ -321,7 +350,10 @@ void main() { ), HostedUiPlatform.token, ); - await plugin.configure(config: mockConfig); + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); await expectLater(plugin.getUserPoolTokens(), completes); await expectLater( From d0a254e2e325d115f85bf90efeb2a1eb7f6cf492 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Thu, 21 Jul 2022 12:50:37 -0800 Subject: [PATCH 25/33] feat(core,api): IAM auth mode for HTTP requests (REST and GQL) (#1893) --- .../api/auth/api_authorization_type.dart | 2 +- .../types/common/amplify_auth_provider.dart | 14 ++ .../amplify_authorization_rest_client.dart | 30 ++-- .../amplify_api/lib/src/api_plugin_impl.dart | 64 ++++--- .../decorators/authorize_http_request.dart | 110 ++++++++++++ .../app_sync_api_key_auth_provider.dart | 38 +++++ packages/api/amplify_api/pubspec.yaml | 1 + .../test/amplify_dart_rest_methods_test.dart | 5 +- .../test/authorize_http_request_test.dart | 159 ++++++++++++++++++ .../amplify_api/test/dart_graphql_test.dart | 2 +- .../test/plugin_configuration_test.dart | 112 ++++++++++++ packages/api/amplify_api/test/util.dart | 53 ++++++ 12 files changed, 538 insertions(+), 52 deletions(-) create mode 100644 packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart create mode 100644 packages/api/amplify_api/lib/src/graphql/app_sync_api_key_auth_provider.dart create mode 100644 packages/api/amplify_api/test/authorize_http_request_test.dart create mode 100644 packages/api/amplify_api/test/plugin_configuration_test.dart create mode 100644 packages/api/amplify_api/test/util.dart diff --git a/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart b/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart index f15da13b9f..95b73a4cac 100644 --- a/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart +++ b/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart @@ -35,7 +35,7 @@ enum APIAuthorizationType<T extends AmplifyAuthProvider> { /// See also: /// - [API Key Authorization](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#api-key-authorization) @JsonValue('API_KEY') - apiKey(AmplifyAuthProviderToken<AmplifyAuthProvider>()), + apiKey(AmplifyAuthProviderToken<ApiKeyAmplifyAuthProvider>()), /// Use an IAM access/secret key credential pair to authorize access to an API. /// diff --git a/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart b/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart index 30c00ff053..16707d6afd 100644 --- a/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart +++ b/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart @@ -34,6 +34,12 @@ class IamAuthProviderOptions extends AuthProviderOptions { const IamAuthProviderOptions({required this.region, required this.service}); } +class ApiKeyAuthProviderOptions extends AuthProviderOptions { + final String apiKey; + + const ApiKeyAuthProviderOptions(this.apiKey); +} + abstract class AmplifyAuthProvider { Future<AWSBaseHttpRequest> authorizeRequest( AWSBaseHttpRequest request, { @@ -50,6 +56,14 @@ abstract class AWSIamAmplifyAuthProvider extends AmplifyAuthProvider }); } +abstract class ApiKeyAmplifyAuthProvider extends AmplifyAuthProvider { + @override + Future<AWSBaseHttpRequest> authorizeRequest( + AWSBaseHttpRequest request, { + covariant ApiKeyAuthProviderOptions? options, + }); +} + abstract class TokenAmplifyAuthProvider extends AmplifyAuthProvider { Future<String> getLatestAuthToken(); diff --git a/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart index 8a2d0678b5..a0b7aece44 100644 --- a/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart +++ b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart @@ -18,15 +18,19 @@ import 'package:amplify_core/amplify_core.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; -const _xApiKey = 'X-Api-Key'; +import 'decorators/authorize_http_request.dart'; /// Implementation of http [http.Client] that authorizes HTTP requests with /// Amplify. @internal class AmplifyAuthorizationRestClient extends http.BaseClient implements Closeable { + /// [AmplifyAuthProviderRepository] for any auth modes this client may use. + final AmplifyAuthProviderRepository authProviderRepo; + /// Determines how requests with this client are authorized. final AWSApiConfig endpointConfig; + final http.Client _baseClient; final bool _useDefaultBaseClient; @@ -34,6 +38,7 @@ class AmplifyAuthorizationRestClient extends http.BaseClient /// client are authorized. AmplifyAuthorizationRestClient({ required this.endpointConfig, + required this.authProviderRepo, http.Client? baseClient, }) : _useDefaultBaseClient = baseClient == null, _baseClient = baseClient ?? http.Client(); @@ -42,27 +47,14 @@ class AmplifyAuthorizationRestClient extends http.BaseClient /// header already set. @override Future<http.StreamedResponse> send(http.BaseRequest request) async => - _baseClient.send(_authorizeRequest(request)); + _baseClient.send(await authorizeHttpRequest( + request, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + )); @override void close() { if (_useDefaultBaseClient) _baseClient.close(); } - - http.BaseRequest _authorizeRequest(http.BaseRequest request) { - if (!request.headers.containsKey(AWSHeaders.authorization) && - endpointConfig.authorizationType != APIAuthorizationType.none) { - // TODO(ragingsquirrel3): Use auth providers from core to transform the request. - final apiKey = endpointConfig.apiKey; - if (endpointConfig.authorizationType == APIAuthorizationType.apiKey) { - if (apiKey == null) { - throw const ApiException( - 'Auth mode is API Key, but no API Key was found in config.'); - } - - request.headers.putIfAbsent(_xApiKey, () => apiKey); - } - } - return request; - } } diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index a5dfd58ce6..e353c70a31 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -26,6 +26,7 @@ import 'package:meta/meta.dart'; import 'amplify_api_config.dart'; import 'amplify_authorization_rest_client.dart'; +import 'graphql/app_sync_api_key_auth_provider.dart'; import 'graphql/send_graphql_request.dart'; import 'util.dart'; @@ -35,10 +36,11 @@ import 'util.dart'; class AmplifyAPIDart extends AmplifyAPI { late final AWSApiPluginConfig _apiConfig; final http.Client? _baseHttpClient; + late final AmplifyAuthProviderRepository _authProviderRepo; /// A map of the keys from the Amplify API config to HTTP clients to use for /// requests to that endpoint. - final Map<String, AmplifyAuthorizationRestClient> _clientPool = {}; + final Map<String, http.Client> _clientPool = {}; /// The registered [APIAuthProvider] instances. final Map<APIAuthorizationType, APIAuthProvider> _authProviders = {}; @@ -65,6 +67,21 @@ class AmplifyAPIDart extends AmplifyAPI { 'https://docs.amplify.aws/lib/graphqlapi/getting-started/q/platform/flutter/#configure-api'); } _apiConfig = apiConfig; + _authProviderRepo = authProviderRepo; + _registerApiPluginAuthProviders(); + } + + /// If an endpoint has an API key, ensure valid auth provider registered. + void _registerApiPluginAuthProviders() { + _apiConfig.endpoints.forEach((key, value) { + // Check the presence of apiKey (not auth type) because other modes might + // have a key if not the primary auth mode. + if (value.apiKey != null) { + _authProviderRepo.registerAuthProvider( + value.authorizationType.authProviderToken, + AppSyncApiKeyAuthProvider()); + } + }); } @override @@ -89,32 +106,21 @@ class AmplifyAPIDart extends AmplifyAPI { } } - /// Returns the HTTP client to be used for GraphQL operations. + /// Returns the HTTP client to be used for REST/GraphQL operations. /// - /// Use [apiName] if there are multiple GraphQL endpoints. + /// Use [apiName] if there are multiple endpoints of the same type. @visibleForTesting - http.Client getGraphQLClient({String? apiName}) { + http.Client getHttpClient(EndpointType type, {String? apiName}) { final endpoint = _apiConfig.getEndpoint( - type: EndpointType.graphQL, + type: type, apiName: apiName, ); - return _clientPool[endpoint.name] ??= AmplifyAuthorizationRestClient( - endpointConfig: endpoint.config, baseClient: _baseHttpClient); - } - - /// Returns the HTTP client to be used for REST operations. - /// - /// Use [apiName] if there are multiple REST endpoints. - @visibleForTesting - http.Client getRestClient({String? apiName}) { - final endpoint = _apiConfig.getEndpoint( - type: EndpointType.rest, - apiName: apiName, - ); - return _clientPool[endpoint.name] ??= AmplifyAuthorizationRestClient( + return _clientPool[endpoint.name] ??= AmplifyHttpClient( + baseClient: AmplifyAuthorizationRestClient( endpointConfig: endpoint.config, baseClient: _baseHttpClient, - ); + authProviderRepo: _authProviderRepo, + )); } Uri _getGraphQLUri(String? apiName) { @@ -160,7 +166,8 @@ class AmplifyAPIDart extends AmplifyAPI { @override CancelableOperation<GraphQLResponse<T>> query<T>( {required GraphQLRequest<T> request}) { - final graphQLClient = getGraphQLClient(apiName: request.apiName); + final graphQLClient = + getHttpClient(EndpointType.graphQL, apiName: request.apiName); final uri = _getGraphQLUri(request.apiName); final responseFuture = sendGraphQLRequest<T>( @@ -171,7 +178,8 @@ class AmplifyAPIDart extends AmplifyAPI { @override CancelableOperation<GraphQLResponse<T>> mutate<T>( {required GraphQLRequest<T> request}) { - final graphQLClient = getGraphQLClient(apiName: request.apiName); + final graphQLClient = + getHttpClient(EndpointType.graphQL, apiName: request.apiName); final uri = _getGraphQLUri(request.apiName); final responseFuture = sendGraphQLRequest<T>( @@ -190,7 +198,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse(AWSStreamedHttpRequest.delete( uri, body: body ?? HttpPayload.empty(), @@ -206,7 +214,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSHttpRequest.get( uri, @@ -223,7 +231,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSHttpRequest.head( uri, @@ -241,7 +249,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSStreamedHttpRequest.patch( uri, @@ -260,7 +268,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSStreamedHttpRequest.post( uri, @@ -279,7 +287,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSStreamedHttpRequest.put( uri, diff --git a/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart b/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart new file mode 100644 index 0000000000..3cab4d7443 --- /dev/null +++ b/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart @@ -0,0 +1,110 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:async'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +/// Transforms an HTTP request according to auth providers that match the endpoint +/// configuration. +@internal +Future<http.BaseRequest> authorizeHttpRequest(http.BaseRequest request, + {required AWSApiConfig endpointConfig, + required AmplifyAuthProviderRepository authProviderRepo}) async { + if (request.headers.containsKey(AWSHeaders.authorization)) { + return request; + } + final authType = endpointConfig.authorizationType; + + switch (authType) { + case APIAuthorizationType.apiKey: + final authProvider = _validateAuthProvider( + authProviderRepo + .getAuthProvider(APIAuthorizationType.apiKey.authProviderToken), + authType); + final apiKey = endpointConfig.apiKey; + if (apiKey == null) { + throw const ApiException( + 'Auth mode is API Key, but no API Key was found in config.'); + } + + final authorizedRequest = await authProvider.authorizeRequest( + _httpToAWSRequest(request), + options: ApiKeyAuthProviderOptions(apiKey)); + return authorizedRequest.httpRequest; + case APIAuthorizationType.iam: + final authProvider = _validateAuthProvider( + authProviderRepo + .getAuthProvider(APIAuthorizationType.iam.authProviderToken), + authType); + final service = endpointConfig.endpointType == EndpointType.graphQL + ? AWSService.appSync + : AWSService.apiGatewayManagementApi; // resolves to "execute-api" + + final authorizedRequest = await authProvider.authorizeRequest( + _httpToAWSRequest(request), + options: IamAuthProviderOptions( + region: endpointConfig.region, + service: service, + ), + ); + return authorizedRequest.httpRequest; + case APIAuthorizationType.function: + case APIAuthorizationType.oidc: + case APIAuthorizationType.userPools: + throw UnimplementedError('${authType.name} not implemented.'); + case APIAuthorizationType.none: + return request; + } +} + +T _validateAuthProvider<T extends AmplifyAuthProvider>( + T? authProvider, APIAuthorizationType authType) { + if (authProvider == null) { + throw ApiException('No auth provider found for auth mode ${authType.name}.', + recoverySuggestion: 'Ensure auth plugin correctly configured.'); + } + return authProvider; +} + +AWSBaseHttpRequest _httpToAWSRequest(http.BaseRequest request) { + final method = AWSHttpMethod.fromString(request.method); + if (request is http.Request) { + return AWSHttpRequest( + method: method, + uri: request.url, + headers: { + AWSHeaders.contentType: 'application/x-amz-json-1.1', + ...request.headers, + }, + body: request.bodyBytes, + ); + } else if (request is http.StreamedRequest) { + return AWSStreamedHttpRequest( + method: method, + uri: request.url, + headers: { + AWSHeaders.contentType: 'application/x-amz-json-1.1', + ...request.headers, + }, + body: request.finalize(), + ); + } else { + throw UnimplementedError( + 'Multipart HTTP requests are not supported.', + ); + } +} diff --git a/packages/api/amplify_api/lib/src/graphql/app_sync_api_key_auth_provider.dart b/packages/api/amplify_api/lib/src/graphql/app_sync_api_key_auth_provider.dart new file mode 100644 index 0000000000..bdafe6dbed --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/app_sync_api_key_auth_provider.dart @@ -0,0 +1,38 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:async'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:meta/meta.dart'; + +/// "X-Api-Key", key used for API key header in API key auth mode. +const xApiKey = 'X-Api-Key'; + +/// [AmplifyAuthProvider] implementation that puts an API key in the header. +@internal +class AppSyncApiKeyAuthProvider extends ApiKeyAmplifyAuthProvider { + @override + Future<AWSBaseHttpRequest> authorizeRequest( + AWSBaseHttpRequest request, { + ApiKeyAuthProviderOptions? options, + }) async { + if (options == null) { + throw const ApiException( + 'Called API key auth provider without passing a valid API key.'); + } + request.headers.putIfAbsent(xApiKey, () => options.apiKey); + return request; + } +} diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index c82797fc29..a4b2121efe 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -29,6 +29,7 @@ dev_dependencies: path: ../../amplify_lints amplify_test: path: ../../amplify_test + aws_signature_v4: ^0.1.0 build_runner: ^2.0.0 flutter_test: sdk: flutter diff --git a/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart b/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart index d8c5162377..8469354830 100644 --- a/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart +++ b/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart @@ -11,8 +11,6 @@ // 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. -import 'dart:convert'; - import 'package:amplify_api/amplify_api.dart'; import 'package:amplify_api/src/api_plugin_impl.dart'; import 'package:amplify_core/amplify_core.dart'; @@ -28,7 +26,8 @@ const _pathThatShouldFail = 'notHere'; class MockAmplifyAPI extends AmplifyAPIDart { @override - http.Client getRestClient({String? apiName}) => MockClient((request) async { + http.Client getHttpClient(EndpointType type, {String? apiName}) => + MockClient((request) async { if (request.body.isNotEmpty) { expect(request.headers['Content-Type'], 'application/json'); } diff --git a/packages/api/amplify_api/test/authorize_http_request_test.dart b/packages/api/amplify_api/test/authorize_http_request_test.dart new file mode 100644 index 0000000000..2179a07ad8 --- /dev/null +++ b/packages/api/amplify_api/test/authorize_http_request_test.dart @@ -0,0 +1,159 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +// A copy of the License is located at +// +// http://aws.amazon.com/apache2.0 +// +// or in the "license" file accompanying this file. This file 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. + +import 'package:amplify_api/src/decorators/authorize_http_request.dart'; +import 'package:amplify_api/src/graphql/app_sync_api_key_auth_provider.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'util.dart'; + +const _region = 'us-east-1'; +const _gqlEndpoint = + 'https://abc123.appsync-api.$_region.amazonaws.com/graphql'; +const _restEndpoint = 'https://xyz456.execute-api.$_region.amazonaws.com/test'; + +http.Request _generateTestRequest(String url) { + return http.Request('GET', Uri.parse(url)); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final authProviderRepo = AmplifyAuthProviderRepository(); + + setUpAll(() { + authProviderRepo.registerAuthProvider( + APIAuthorizationType.apiKey.authProviderToken, + AppSyncApiKeyAuthProvider()); + authProviderRepo.registerAuthProvider( + APIAuthorizationType.iam.authProviderToken, TestIamAuthProvider()); + }); + + group('authorizeHttpRequest', () { + test('no-op for auth mode NONE', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.none, + endpoint: _restEndpoint, + endpointType: EndpointType.rest, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + expect(authorizedRequest.headers.containsKey(AWSHeaders.authorization), + isFalse); + expect(authorizedRequest, inputRequest); + }); + + test('no-op for request with Authorization header already set', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.userPools, + endpoint: _restEndpoint, + endpointType: EndpointType.rest, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + const testAuthValue = 'foo'; + inputRequest.headers + .putIfAbsent(AWSHeaders.authorization, () => testAuthValue); + + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + expect( + authorizedRequest.headers[AWSHeaders.authorization], testAuthValue); + expect(authorizedRequest, inputRequest); + }); + + test('authorizes request with IAM auth provider', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.iam, + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + validateSignedRequest(authorizedRequest); + }); + + test('authorizes request with API key', () async { + const testApiKey = 'abc-123-fake-key'; + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.apiKey, + apiKey: testApiKey, + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + expect( + authorizedRequest.headers[xApiKey], + testApiKey, + ); + }); + + test('throws when API key not in config', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.apiKey, + // no apiKey value provided + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + expectLater( + authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ), + throwsA(isA<ApiException>())); + }); + + test('authorizes with Cognito User Pools auth mode', () {}, skip: true); + + test('authorizes with OIDC auth mode', () {}, skip: true); + + test('authorizes with lambda auth mode', () {}, skip: true); + + test('throws when no auth provider found', () async { + final emptyAuthRepo = AmplifyAuthProviderRepository(); + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.apiKey, + apiKey: 'abc-123-fake-key', + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + expectLater( + authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: emptyAuthRepo, + ), + throwsA(isA<ApiException>())); + }); + }); +} diff --git a/packages/api/amplify_api/test/dart_graphql_test.dart b/packages/api/amplify_api/test/dart_graphql_test.dart index bedd0092f2..4d9d8ec47f 100644 --- a/packages/api/amplify_api/test/dart_graphql_test.dart +++ b/packages/api/amplify_api/test/dart_graphql_test.dart @@ -91,7 +91,7 @@ class MockAmplifyAPI extends AmplifyAPIDart { }) : super(modelProvider: modelProvider); @override - http.Client getGraphQLClient({String? apiName}) => + http.Client getHttpClient(EndpointType type, {String? apiName}) => MockClient((request) async { if (request.body.contains('getBlog')) { return http.Response(json.encode(_expectedModelQueryResult), 200); diff --git a/packages/api/amplify_api/test/plugin_configuration_test.dart b/packages/api/amplify_api/test/plugin_configuration_test.dart new file mode 100644 index 0000000000..fcf3692114 --- /dev/null +++ b/packages/api/amplify_api/test/plugin_configuration_test.dart @@ -0,0 +1,112 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. +import 'dart:convert'; + +import 'package:amplify_api/src/api_plugin_impl.dart'; +import 'package:amplify_api/src/graphql/app_sync_api_key_auth_provider.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'test_data/fake_amplify_configuration.dart'; +import 'util.dart'; + +const _expectedQuerySuccessResponseBody = { + 'data': { + 'listBlogs': { + 'items': [ + { + 'id': 'TEST_ID', + 'name': 'Test App Blog', + 'createdAt': '2022-06-28T17:36:52.460Z' + } + ] + } + } +}; + +/// Asserts user agent and API key present. +final _mockGqlClient = MockClient((request) async { + const userAgentHeader = + zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; + expect(request.headers[userAgentHeader], contains('amplify-flutter')); + expect(request.headers[xApiKey], isA<String>()); + return http.Response(json.encode(_expectedQuerySuccessResponseBody), 200); +}); + +/// Asserts user agent and signed. +final _mockRestClient = MockClient((request) async { + const userAgentHeader = + zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; + expect(request.headers[userAgentHeader], contains('amplify-flutter')); + validateSignedRequest(request); + return http.Response('"Hello from Lambda!"', 200); +}); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final authProviderRepo = AmplifyAuthProviderRepository(); + authProviderRepo.registerAuthProvider( + APIAuthorizationType.iam.authProviderToken, TestIamAuthProvider()); + final config = + AmplifyConfig.fromJson(jsonDecode(amplifyconfig) as Map<String, Object?>); + + group('AmplifyAPI plugin configuration', () { + test( + 'should register an API key auth provider when the configuration has an API key', + () async { + final plugin = AmplifyAPIDart(); + await plugin.configure( + authProviderRepo: authProviderRepo, config: config); + final apiKeyAuthProvider = authProviderRepo + .getAuthProvider(APIAuthorizationType.apiKey.authProviderToken); + expect(apiKeyAuthProvider, isA<AppSyncApiKeyAuthProvider>()); + }); + + test( + 'should configure an HTTP client for GraphQL that authorizes with auth providers and adds user-agent', + () async { + final plugin = AmplifyAPIDart(baseHttpClient: _mockGqlClient); + await plugin.configure( + authProviderRepo: authProviderRepo, config: config); + + String graphQLDocument = '''query TestQuery { + listBlogs { + items { + id + name + createdAt + } + } + }'''; + final request = + GraphQLRequest<String>(document: graphQLDocument, variables: {}); + await plugin.query(request: request).value; + // no assertion here because assertion implemented in mock HTTP client + }); + + test( + 'should configure an HTTP client for REST that authorizes with auth providers and adds user-agent', + () async { + final plugin = AmplifyAPIDart(baseHttpClient: _mockRestClient); + await plugin.configure( + authProviderRepo: authProviderRepo, config: config); + + await plugin.get('/items').value; + // no assertion here because assertion implemented in mock HTTP client + }); + }); +} diff --git a/packages/api/amplify_api/test/util.dart b/packages/api/amplify_api/test/util.dart new file mode 100644 index 0000000000..f3c2ef551e --- /dev/null +++ b/packages/api/amplify_api/test/util.dart @@ -0,0 +1,53 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'package:amplify_core/amplify_core.dart'; +import 'package:aws_signature_v4/aws_signature_v4.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +class TestIamAuthProvider extends AWSIamAmplifyAuthProvider { + @override + Future<AWSCredentials> retrieve() async { + return const AWSCredentials( + 'fake-access-key-123', 'fake-secret-access-key-456'); + } + + @override + Future<AWSSignedRequest> authorizeRequest( + AWSBaseHttpRequest request, { + IamAuthProviderOptions? options, + }) async { + final signer = AWSSigV4Signer( + credentialsProvider: AWSCredentialsProvider(await retrieve()), + ); + final scope = AWSCredentialScope( + region: options!.region, + service: AWSService.appSync, + ); + return signer.sign( + request, + credentialScope: scope, + ); + } +} + +void validateSignedRequest(http.BaseRequest request) { + const userAgentHeader = + zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; + expect( + request.headers[userAgentHeader], + contains('aws-sigv4'), + ); +} From c63ddd6025670dddced42305a486fec2a314afd9 Mon Sep 17 00:00:00 2001 From: Elijah Quartey <Equartey@users.noreply.github.com> Date: Fri, 29 Jul 2022 09:31:47 -0500 Subject: [PATCH 26/33] feat(api): GraphQL Custom Request Headers (#1938) --- .../lib/src/types/api/graphql/graphql_request.dart | 5 +++++ .../amplify_api/lib/src/graphql/send_graphql_request.dart | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/amplify_core/lib/src/types/api/graphql/graphql_request.dart b/packages/amplify_core/lib/src/types/api/graphql/graphql_request.dart index ff77c1713c..26778fdee7 100644 --- a/packages/amplify_core/lib/src/types/api/graphql/graphql_request.dart +++ b/packages/amplify_core/lib/src/types/api/graphql/graphql_request.dart @@ -22,6 +22,9 @@ class GraphQLRequest<T> { /// Only required if your backend has multiple GraphQL endpoints in the amplifyconfiguration.dart file. This parameter is then needed to specify which one to use for this request. final String? apiName; + /// A map of Strings to dynamically use for custom headers in the http request. + final Map<String, String>? headers; + /// The body of the request, starting with the operation type and operation name. /// /// See https://graphql.org/learn/queries/#operation-name for examples and more information. @@ -57,12 +60,14 @@ class GraphQLRequest<T> { {this.apiName, required this.document, this.variables = const <String, dynamic>{}, + this.headers, this.decodePath, this.modelType}); Map<String, dynamic> serializeAsMap() => <String, dynamic>{ 'document': document, 'variables': variables, + 'headers': headers, 'cancelToken': id, if (apiName != null) 'apiName': apiName, }; diff --git a/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart b/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart index 6eab7deadd..3ba0a36c7d 100644 --- a/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart +++ b/packages/api/amplify_api/lib/src/graphql/send_graphql_request.dart @@ -31,7 +31,8 @@ Future<GraphQLResponse<T>> sendGraphQLRequest<T>({ }) async { try { final body = {'variables': request.variables, 'query': request.document}; - final graphQLResponse = await client.post(uri, body: json.encode(body)); + final graphQLResponse = await client.post(uri, + body: json.encode(body), headers: request.headers); final responseBody = json.decode(graphQLResponse.body); From 64a50f620cfcd391aeec659c45105767453db9fc Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Mon, 8 Aug 2022 08:58:01 -0800 Subject: [PATCH 27/33] feat(auth,api): cognito user pools auth provider & auth mode for API HTTP requests (#1913) --- .../decorators/authorize_http_request.dart | 9 +- .../test/authorize_http_request_test.dart | 34 ++- packages/api/amplify_api/test/util.dart | 9 + .../lib/src/auth_plugin_impl.dart | 14 +- .../cognito_user_pools_auth_provider.dart | 37 +++ .../test/plugin/auth_providers_test.dart | 210 ++++++++++++++---- 6 files changed, 255 insertions(+), 58 deletions(-) create mode 100644 packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_user_pools_auth_provider.dart diff --git a/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart b/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart index 3cab4d7443..24a343895e 100644 --- a/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart +++ b/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart @@ -64,8 +64,15 @@ Future<http.BaseRequest> authorizeHttpRequest(http.BaseRequest request, return authorizedRequest.httpRequest; case APIAuthorizationType.function: case APIAuthorizationType.oidc: - case APIAuthorizationType.userPools: throw UnimplementedError('${authType.name} not implemented.'); + case APIAuthorizationType.userPools: + final authProvider = _validateAuthProvider( + authProviderRepo.getAuthProvider(authType.authProviderToken), + authType, + ); + final authorizedRequest = + await authProvider.authorizeRequest(_httpToAWSRequest(request)); + return authorizedRequest.httpRequest; case APIAuthorizationType.none: return request; } diff --git a/packages/api/amplify_api/test/authorize_http_request_test.dart b/packages/api/amplify_api/test/authorize_http_request_test.dart index 2179a07ad8..3f1ad3754d 100644 --- a/packages/api/amplify_api/test/authorize_http_request_test.dart +++ b/packages/api/amplify_api/test/authorize_http_request_test.dart @@ -33,11 +33,19 @@ void main() { final authProviderRepo = AmplifyAuthProviderRepository(); setUpAll(() { - authProviderRepo.registerAuthProvider( + authProviderRepo + ..registerAuthProvider( APIAuthorizationType.apiKey.authProviderToken, - AppSyncApiKeyAuthProvider()); - authProviderRepo.registerAuthProvider( - APIAuthorizationType.iam.authProviderToken, TestIamAuthProvider()); + AppSyncApiKeyAuthProvider(), + ) + ..registerAuthProvider( + APIAuthorizationType.iam.authProviderToken, + TestIamAuthProvider(), + ) + ..registerAuthProvider( + APIAuthorizationType.userPools.authProviderToken, + TestTokenAuthProvider(), + ); }); group('authorizeHttpRequest', () { @@ -132,7 +140,23 @@ void main() { throwsA(isA<ApiException>())); }); - test('authorizes with Cognito User Pools auth mode', () {}, skip: true); + test('authorizes with Cognito User Pools auth mode', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.userPools, + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + expect( + authorizedRequest.headers[AWSHeaders.authorization], + testAccessToken, + ); + }); test('authorizes with OIDC auth mode', () {}, skip: true); diff --git a/packages/api/amplify_api/test/util.dart b/packages/api/amplify_api/test/util.dart index f3c2ef551e..cd06f8c13c 100644 --- a/packages/api/amplify_api/test/util.dart +++ b/packages/api/amplify_api/test/util.dart @@ -17,6 +17,8 @@ import 'package:aws_signature_v4/aws_signature_v4.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; +const testAccessToken = 'test-access-token-123'; + class TestIamAuthProvider extends AWSIamAmplifyAuthProvider { @override Future<AWSCredentials> retrieve() async { @@ -43,6 +45,13 @@ class TestIamAuthProvider extends AWSIamAmplifyAuthProvider { } } +class TestTokenAuthProvider extends TokenAmplifyAuthProvider { + @override + Future<String> getLatestAuthToken() async { + return testAccessToken; + } +} + void validateSignedRequest(http.BaseRequest request) { const userAgentHeader = zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart index 1db9c30481..d1b1810f6e 100644 --- a/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart @@ -52,6 +52,7 @@ import 'package:amplify_auth_cognito_dart/src/sdk/cognito_identity_provider.dart import 'package:amplify_auth_cognito_dart/src/sdk/sdk_bridge.dart'; import 'package:amplify_auth_cognito_dart/src/state/state.dart'; import 'package:amplify_auth_cognito_dart/src/util/cognito_iam_auth_provider.dart'; +import 'package:amplify_auth_cognito_dart/src/util/cognito_user_pools_auth_provider.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:amplify_secure_storage_dart/amplify_secure_storage_dart.dart'; import 'package:built_collection/built_collection.dart'; @@ -185,10 +186,15 @@ class AmplifyAuthCognitoDart extends AuthPluginInterface // Register auth providers to provide auth functionality to other plugins // without requiring other plugins to call `Amplify.Auth...` directly. - authProviderRepo.registerAuthProvider( - APIAuthorizationType.iam.authProviderToken, - CognitoIamAuthProvider(), - ); + authProviderRepo + ..registerAuthProvider( + APIAuthorizationType.iam.authProviderToken, + CognitoIamAuthProvider(), + ) + ..registerAuthProvider( + APIAuthorizationType.userPools.authProviderToken, + CognitoUserPoolsAuthProvider(), + ); if (_stateMachine.getOrCreate(AuthStateMachine.type).currentState.type != AuthStateType.notConfigured) { diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_user_pools_auth_provider.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_user_pools_auth_provider.dart new file mode 100644 index 0000000000..edde7c3bca --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/util/cognito_user_pools_auth_provider.dart @@ -0,0 +1,37 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. 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. + +import 'dart:async'; + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:meta/meta.dart'; + +/// [AmplifyAuthProvider] implementation that adds access token to request headers. +@internal +class CognitoUserPoolsAuthProvider extends TokenAmplifyAuthProvider { + /// Get access token from `Amplify.Auth.fetchAuthSession()`. + @override + Future<String> getLatestAuthToken() async { + final authSession = + await Amplify.Auth.fetchAuthSession() as CognitoAuthSession; + final token = authSession.userPoolTokens?.accessToken.raw; + if (token == null) { + throw const AuthException( + 'Unable to fetch access token while authorizing with Cognito User Pools.', + ); + } + return token; + } +} diff --git a/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart b/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart index acb126fa66..de1d20b496 100644 --- a/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart +++ b/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart @@ -15,7 +15,9 @@ import 'dart:async'; import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart' hide InternalErrorException; +import 'package:amplify_auth_cognito_dart/src/credentials/cognito_keys.dart'; import 'package:amplify_auth_cognito_dart/src/util/cognito_iam_auth_provider.dart'; +import 'package:amplify_auth_cognito_dart/src/util/cognito_user_pools_auth_provider.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:test/test.dart'; @@ -29,84 +31,196 @@ AWSHttpRequest _generateTestRequest() { ); } -/// Returns dummy AWS credentials. -class TestAmplifyAuth extends AmplifyAuthCognitoDart { +/// Mock implementation of user pool only error when trying to get credentials. +class TestAmplifyAuthUserPoolOnly extends AmplifyAuthCognitoDart { @override Future<AuthSession> fetchAuthSession({ required AuthSessionRequest request, }) async { - return const CognitoAuthSession( + final options = request.options as CognitoSessionOptions?; + final getAWSCredentials = options?.getAWSCredentials; + if (getAWSCredentials != null && getAWSCredentials) { + throw const InvalidAccountTypeException.noIdentityPool( + recoverySuggestion: + 'Register an identity pool using the CLI or set getAWSCredentials ' + 'to false', + ); + } + return CognitoAuthSession( isSignedIn: true, - credentials: AWSCredentials('fakeKeyId', 'fakeSecret'), + userPoolTokens: CognitoUserPoolTokens( + accessToken: accessToken, + idToken: idToken, + refreshToken: refreshToken, + ), ); } } void main() { + late AmplifyAuthCognitoDart plugin; + late AmplifyAuthProviderRepository testAuthRepo; + + setUpAll(() async { + testAuthRepo = AmplifyAuthProviderRepository(); + final secureStorage = MockSecureStorage(); + final stateMachine = CognitoAuthStateMachine()..addInstance(secureStorage); + plugin = AmplifyAuthCognitoDart(credentialStorage: secureStorage) + ..stateMachine = stateMachine; + + seedStorage( + secureStorage, + userPoolKeys: CognitoUserPoolKeys(userPoolConfig), + identityPoolKeys: CognitoIdentityPoolKeys(identityPoolConfig), + ); + + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + }); + group( 'AmplifyAuthCognitoDart plugin registers auth providers during configuration', () { - late AmplifyAuthCognitoDart plugin; - - setUp(() async { - plugin = AmplifyAuthCognitoDart(credentialStorage: MockSecureStorage()); - }); - test('registers CognitoIamAuthProvider', () async { - final testAuthRepo = AmplifyAuthProviderRepository(); - await plugin.configure( - config: mockConfig, - authProviderRepo: testAuthRepo, - ); final authProvider = testAuthRepo.getAuthProvider( APIAuthorizationType.iam.authProviderToken, ); expect(authProvider, isA<CognitoIamAuthProvider>()); }); - }); - - group('CognitoIamAuthProvider', () { - setUpAll(() async { - await Amplify.addPlugin(TestAmplifyAuth()); - }); - test('gets AWS credentials from Amplify.Auth.fetchAuthSession', () async { - final authProvider = CognitoIamAuthProvider(); - final credentials = await authProvider.retrieve(); - expect(credentials.accessKeyId, isA<String>()); - expect(credentials.secretAccessKey, isA<String>()); + test('registers CognitoUserPoolsAuthProvider', () async { + final authProvider = testAuthRepo.getAuthProvider( + APIAuthorizationType.userPools.authProviderToken, + ); + expect(authProvider, isA<CognitoUserPoolsAuthProvider>()); }); + }); - test('signs a request when calling authorizeRequest', () async { + group('no auth plugin added', () { + test('CognitoIamAuthProvider throws when trying to authorize a request', + () async { final authProvider = CognitoIamAuthProvider(); - final authorizedRequest = await authProvider.authorizeRequest( - _generateTestRequest(), - options: const IamAuthProviderOptions( - region: 'us-east-1', - service: AWSService.appSync, + await expectLater( + authProvider.authorizeRequest( + _generateTestRequest(), + options: const IamAuthProviderOptions( + region: 'us-east-1', + service: AWSService.appSync, + ), ), - ); - // Note: not intended to be complete test of sigv4 algorithm. - expect(authorizedRequest.headers[AWSHeaders.authorization], isNotEmpty); - const userAgentHeader = - zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; - expect( - authorizedRequest.headers[AWSHeaders.host], - isNotEmpty, - skip: zIsWeb, - ); - expect( - authorizedRequest.headers[userAgentHeader], - contains('aws-sigv4'), + throwsA(isA<AmplifyException>()), ); }); - test('throws when no options provided', () async { - final authProvider = CognitoIamAuthProvider(); + test('CognitoUserPoolsAuthProvider throws when trying to authorize request', + () async { + final authProvider = CognitoUserPoolsAuthProvider(); await expectLater( authProvider.authorizeRequest(_generateTestRequest()), - throwsA(isA<AuthException>()), + throwsA(isA<AmplifyException>()), ); }); }); + + group('auth providers defined in auth plugin', () { + setUpAll(() async { + await Amplify.reset(); + await Amplify.addPlugin(plugin); + }); + + group('CognitoIamAuthProvider', () { + test('gets AWS credentials from Amplify.Auth.fetchAuthSession', () async { + final authProvider = CognitoIamAuthProvider(); + final credentials = await authProvider.retrieve(); + expect(credentials.accessKeyId, isA<String>()); + expect(credentials.secretAccessKey, isA<String>()); + }); + + test('signs a request when calling authorizeRequest', () async { + final authProvider = CognitoIamAuthProvider(); + final authorizedRequest = await authProvider.authorizeRequest( + _generateTestRequest(), + options: const IamAuthProviderOptions( + region: 'us-east-1', + service: AWSService.appSync, + ), + ); + // Note: not intended to be complete test of sigv4 algorithm. + expect(authorizedRequest.headers[AWSHeaders.authorization], isNotEmpty); + const userAgentHeader = + zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; + expect( + authorizedRequest.headers[AWSHeaders.host], + isNotEmpty, + skip: zIsWeb, + ); + expect( + authorizedRequest.headers[userAgentHeader], + contains('aws-sigv4'), + ); + }); + + test('throws when no options provided', () async { + final authProvider = CognitoIamAuthProvider(); + await expectLater( + authProvider.authorizeRequest(_generateTestRequest()), + throwsA(isA<AuthException>()), + ); + }); + }); + + group('CognitoUserPoolsAuthProvider', () { + test('gets raw access token from Amplify.Auth.fetchAuthSession', + () async { + final authProvider = CognitoUserPoolsAuthProvider(); + final token = await authProvider.getLatestAuthToken(); + expect(token, accessToken.raw); + }); + + test('adds access token to header when calling authorizeRequest', + () async { + final authProvider = CognitoUserPoolsAuthProvider(); + final authorizedRequest = await authProvider.authorizeRequest( + _generateTestRequest(), + ); + expect( + authorizedRequest.headers[AWSHeaders.authorization], + accessToken.raw, + ); + }); + }); + }); + + group('auth providers with user pool-only configuration', () { + setUpAll(() async { + await Amplify.reset(); + await Amplify.addPlugin(TestAmplifyAuthUserPoolOnly()); + }); + + group('CognitoIamAuthProvider', () { + test('throws when trying to retrieve credentials', () async { + final authProvider = CognitoIamAuthProvider(); + await expectLater( + authProvider.retrieve(), + throwsA(isA<InvalidAccountTypeException>()), + ); + }); + }); + + group('CognitoUserPoolsAuthProvider', () { + test('adds access token to header when calling authorizeRequest', + () async { + final authProvider = CognitoUserPoolsAuthProvider(); + final authorizedRequest = await authProvider.authorizeRequest( + _generateTestRequest(), + ); + expect( + authorizedRequest.headers[AWSHeaders.authorization], + accessToken.raw, + ); + }); + }); + }); } From 8b125c668c9f913ac9c48da0327b5df28ac48c10 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Thu, 18 Aug 2022 07:05:03 -0700 Subject: [PATCH 28/33] correct error handling --- .../src/graphql/ws/web_socket_connection.dart | 66 ++++++++++++------- ...web_socket_message_stream_transformer.dart | 1 + .../test/ws/web_socket_connection_test.dart | 20 +++--- .../lib/src/auth_plugin_impl.dart | 7 +- 4 files changed, 59 insertions(+), 35 deletions(-) diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart index 683a10afb7..bb21665953 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart @@ -72,12 +72,15 @@ class WebSocketConnection implements Closeable { @visibleForTesting StreamSubscription<WebSocketMessage> getStreamSubscription( Stream<dynamic> stream) { - return stream - .transform(const WebSocketMessageStreamTransformer()) - .listen(_onData, onError: (Object e) { - _connectionError(ApiException('Connection failed.', - underlyingException: e.toString())); - }); + return stream.transform(const WebSocketMessageStreamTransformer()).listen( + _onData, + cancelOnError: true, + onError: (Object e) { + _connectionError( + ApiException('Connection failed.', underlyingException: e.toString()), + ); + }, + ); } /// Connects WebSocket channel to _subscription stream but does not send connection @@ -92,7 +95,7 @@ class WebSocketConnection implements Closeable { } void _connectionError(ApiException exception) { - _connectionReady.completeError(_connectionError); + _connectionReady.completeError(exception); _channel?.sink.close(); _resetConnectionInit(); } @@ -134,35 +137,54 @@ class WebSocketConnection implements Closeable { return ready; } + Future<void> _sendSubscriptionRegistrationMessage<T>( + GraphQLRequest<T> request) async { + await init(); // no-op if already connected + final subscriptionRegistrationMessage = + await generateSubscriptionRegistrationMessage( + _config, + id: request.id, + authRepo: _authProviderRepo, + request: request, + ); + send(subscriptionRegistrationMessage); + } + /// Subscribes to the given GraphQL request. Returns the subscription object, /// or throws an [Exception] if there's an error. Stream<GraphQLResponse<T>> subscribe<T>( GraphQLRequest<T> request, void Function()? onEstablished, ) { - final subscriptionId = uuid(); - - // init is no-op if already connected - init().then((_) { - // Generate and send an authorized subscription registration message. - generateSubscriptionRegistrationMessage( - _config, - id: subscriptionId, - authRepo: _authProviderRepo, - request: request, - ).then(send); - }); + // Create controller for this subscription so we can add errors. + late StreamController<GraphQLResponse<T>> controller; + controller = StreamController<GraphQLResponse<T>>.broadcast( + onCancel: () { + _cancel(request.id); + controller.close(); + }, + ); // Filter incoming messages that have the subscription ID and return as new // stream with messages converted to GraphQLResponse<T>. - return _messageStream - .where((msg) => msg.id == subscriptionId) + _messageStream + .where((msg) => msg.id == request.id) .transform(WebSocketSubscriptionStreamTransformer( request, onEstablished, logger: _logger, )) - .asBroadcastStream(onCancel: (_) => _cancel(subscriptionId)); + .listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + cancelOnError: true, + ); + + _sendSubscriptionRegistrationMessage(request) + .catchError(controller.addError); + + return controller.stream; } /// Cancels a subscription. diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart index f9837c7272..0ecbffcee9 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart @@ -59,6 +59,7 @@ class WebSocketSubscriptionStreamTransformer<T> break; case MessageType.data: final payload = event.payload as SubscriptionDataPayload; + // TODO(ragingsquirrel3): refactor decoder final errors = deserializeGraphQLResponseErrors(payload.toJson()); yield GraphQLResponseDecoder.instance.decode<T>( request: request, diff --git a/packages/api/amplify_api/test/ws/web_socket_connection_test.dart b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart index 81e0d87d6e..632b840781 100644 --- a/packages/api/amplify_api/test/ws/web_socket_connection_test.dart +++ b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart @@ -62,7 +62,7 @@ void main() { Completer<void> establishedCompleter = Completer(); connection.subscribe(subscriptionRequest, () { establishedCompleter.complete(); - }).listen((event) {}); + }); expectLater(connection.ready, completes); expectLater(establishedCompleter.future, completes); @@ -76,7 +76,7 @@ void main() { Completer<void> establishedCompleter = Completer(); connection.subscribe(subscriptionRequest, () { establishedCompleter.complete(); - }).listen((event) {}); + }); await establishedCompleter.future; final lastMessage = connection.lastSentMessage; @@ -88,17 +88,15 @@ void main() { }); test('subscribe() should return a subscription stream', () async { - Completer<void> establishedCompleter = Completer(); Completer<String> dataCompleter = Completer(); final subscription = connection.subscribe( subscriptionRequest, - () => establishedCompleter.complete(), + null, ); final streamSub = subscription.listen( (event) => dataCompleter.complete(event.data), ); - await expectLater(establishedCompleter.future, completes); final subscriptionData = await dataCompleter.future; expect(subscriptionData, json.encode(mockSubscriptionData)); @@ -106,12 +104,12 @@ void main() { }); test('cancel() should send a stop message', () async { - Completer<void> establishedCompleter = Completer(); - final subscription = connection.subscribe(subscriptionRequest, () { - establishedCompleter.complete(); - }); - final streamSub = subscription.listen((event) {}); - await establishedCompleter.future; + Completer<String> dataCompleter = Completer(); + final subscription = connection.subscribe(subscriptionRequest, null); + final streamSub = subscription.listen( + (event) => dataCompleter.complete(event.data), + ); + await dataCompleter.future; streamSub.cancel(); expect(connection.lastSentMessage?.messageType, MessageType.stop); }); diff --git a/packages/auth/amplify_auth_cognito/lib/src/auth_plugin_impl.dart b/packages/auth/amplify_auth_cognito/lib/src/auth_plugin_impl.dart index b4e7585490..c16bc3ebba 100644 --- a/packages/auth/amplify_auth_cognito/lib/src/auth_plugin_impl.dart +++ b/packages/auth/amplify_auth_cognito/lib/src/auth_plugin_impl.dart @@ -87,8 +87,11 @@ class AmplifyAuthCognito extends AmplifyAuthCognitoDart with AWSDebuggable { } @override - Future<void> configure({AmplifyConfig? config}) async { - await super.configure(config: config); + Future<void> configure({ + AmplifyConfig? config, + required AmplifyAuthProviderRepository authProviderRepo, + }) async { + await super.configure(config: config, authProviderRepo: authProviderRepo); // Update the native cache for the current user on hub events. final nativeBridge = stateMachine.get<NativeAuthBridge>(); From 73000249e70b31d7036d9211e07c27c46c443f5a Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Mon, 22 Aug 2022 09:05:41 -0800 Subject: [PATCH 29/33] fix(auth): correct auth providers imports from rebase (#2042) --- .../amplify_auth_cognito/lib/src/auth_plugin_impl.dart | 7 +++++-- .../test/plugin/auth_providers_test.dart | 0 2 files changed, 5 insertions(+), 2 deletions(-) rename packages/auth/{amplify_auth_cognito_dart => amplify_auth_cognito_test}/test/plugin/auth_providers_test.dart (100%) diff --git a/packages/auth/amplify_auth_cognito/lib/src/auth_plugin_impl.dart b/packages/auth/amplify_auth_cognito/lib/src/auth_plugin_impl.dart index b4e7585490..c16bc3ebba 100644 --- a/packages/auth/amplify_auth_cognito/lib/src/auth_plugin_impl.dart +++ b/packages/auth/amplify_auth_cognito/lib/src/auth_plugin_impl.dart @@ -87,8 +87,11 @@ class AmplifyAuthCognito extends AmplifyAuthCognitoDart with AWSDebuggable { } @override - Future<void> configure({AmplifyConfig? config}) async { - await super.configure(config: config); + Future<void> configure({ + AmplifyConfig? config, + required AmplifyAuthProviderRepository authProviderRepo, + }) async { + await super.configure(config: config, authProviderRepo: authProviderRepo); // Update the native cache for the current user on hub events. final nativeBridge = stateMachine.get<NativeAuthBridge>(); diff --git a/packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart b/packages/auth/amplify_auth_cognito_test/test/plugin/auth_providers_test.dart similarity index 100% rename from packages/auth/amplify_auth_cognito_dart/test/plugin/auth_providers_test.dart rename to packages/auth/amplify_auth_cognito_test/test/plugin/auth_providers_test.dart From e1ac0ce9c0c02b06d4cd2ac165dcada2a667a3c6 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Mon, 22 Aug 2022 12:32:46 -0700 Subject: [PATCH 30/33] improve test syntax --- .../src/decorators/web_socket_auth_utils.dart | 39 ++++++++++-------- ...web_socket_message_stream_transformer.dart | 18 ++++++-- .../lib/src/graphql/ws/web_socket_types.dart | 12 ++---- .../amplify_api/test/dart_graphql_test.dart | 41 +++++++++++++++---- .../test/ws/web_socket_connection_test.dart | 20 ++++----- 5 files changed, 79 insertions(+), 51 deletions(-) diff --git a/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart b/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart index f685c3821f..1a6b063588 100644 --- a/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart +++ b/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart @@ -12,6 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +@internal +library amplify_api.decorators.web_socket_auth_utils; + import 'dart:convert'; import 'package:amplify_core/amplify_core.dart'; @@ -22,9 +25,11 @@ import '../graphql/ws/web_socket_types.dart'; import 'authorize_http_request.dart'; // Constants for header values as noted in https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html. -const _acceptHeaderValue = 'application/json, text/javascript'; -const _contentEncodingHeaderValue = 'amz-1.0'; -const _contentTypeHeaderValue = 'application/json; charset=UTF-8'; +const _requiredHeaders = { + AWSHeaders.accept: 'application/json, text/javascript', + AWSHeaders.contentEncoding: 'amz-1.0', + AWSHeaders.contentType: 'application/json; charset=UTF-8', +}; // AppSync expects "{}" encoded in the URI as the payload during handshake. const _emptyBody = '{}'; @@ -32,11 +37,11 @@ const _emptyBody = '{}'; /// Generate a URI for the connection and all subscriptions. /// /// See https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html#handshake-details-to-establish-the-websocket-connection= -@internal Future<Uri> generateConnectionUri( AWSApiConfig config, AmplifyAuthProviderRepository authRepo) async { final authorizationHeaders = await _generateAuthorizationHeaders( config, + isConnectionInit: true, authRepo: authRepo, body: _emptyBody, ); @@ -55,7 +60,6 @@ Future<Uri> generateConnectionUri( /// Generate websocket message with authorized payload to register subscription. /// /// See https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html#subscription-registration-message -@internal Future<WebSocketSubscriptionRegistrationMessage> generateSubscriptionRegistrationMessage( AWSApiConfig config, { @@ -65,8 +69,12 @@ Future<WebSocketSubscriptionRegistrationMessage> }) async { final body = jsonEncode({'variables': request.variables, 'query': request.document}); - final authorizationHeaders = await _generateAuthorizationHeaders(config, - authRepo: authRepo, body: body); + final authorizationHeaders = await _generateAuthorizationHeaders( + config, + isConnectionInit: false, + authRepo: authRepo, + body: body, + ); return WebSocketSubscriptionRegistrationMessage( id: id, @@ -82,29 +90,26 @@ Future<WebSocketSubscriptionRegistrationMessage> /// are formatted correctly to be either encoded into URI query params or subscription /// registration payload headers. /// -/// If body is "{}" then headers are formatted like connection URI. Any other string -/// for body will be formatted as subscription registration. This is done by creating +/// If `isConnectionInit` true then headers are formatted like connection URI. +/// Otherwise body will be formatted as subscription registration. This is done by creating /// a canonical HTTP request that is authorized but never sent. The headers from /// the HTTP request are reformatted and returned. This logic applies for all auth /// modes as determined by [authRepo] parameter. Future<Map<String, String>> _generateAuthorizationHeaders( AWSApiConfig config, { + required bool isConnectionInit, required AmplifyAuthProviderRepository authRepo, required String body, }) async { final endpointHost = Uri.parse(config.endpoint).host; // Create canonical HTTP request to authorize but never send. // - // The canonical request URL is a little different depending on if connection_init - // or start (subscription registration). - final maybeConnect = body != _emptyBody ? '' : '/connect'; + // The canonical request URL is a little different depending on if authorizing + // connection URI or start message (subscription registration). + final maybeConnect = isConnectionInit ? '' : '/connect'; final canonicalHttpRequest = http.Request('POST', Uri.parse('${config.endpoint}$maybeConnect')); - canonicalHttpRequest.headers.addAll({ - AWSHeaders.accept: _acceptHeaderValue, - AWSHeaders.contentEncoding: _contentEncodingHeaderValue, - AWSHeaders.contentType: _contentTypeHeaderValue, - }); + canonicalHttpRequest.headers.addAll(_requiredHeaders); canonicalHttpRequest.body = body; final authorizedHttpRequest = await authorizeHttpRequest( canonicalHttpRequest, diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart index 0ecbffcee9..960a617eac 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart @@ -12,7 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -// ignore_for_file: public_member_api_docs +@internal +library amplify_api.graphql.ws.web_socket_message_stream_transformer; import 'dart:async'; import 'dart:convert'; @@ -24,9 +25,11 @@ import 'package:meta/meta.dart'; import '../graphql_response_decoder.dart'; import 'web_socket_types.dart'; -@internal +/// Top-level transformer. class WebSocketMessageStreamTransformer extends StreamTransformerBase<dynamic, WebSocketMessage> { + /// Transforms raw web socket response (String) to `WebSocketMessage` for all input + /// from channel. const WebSocketMessageStreamTransformer(); @override @@ -37,13 +40,22 @@ class WebSocketMessageStreamTransformer } } -@internal +/// Final level of transformation for converting `WebSocketMessage`s to stream +/// of `GraphQLResponse` that is eventually passed to public API `Amplify.API.subscribe`. class WebSocketSubscriptionStreamTransformer<T> extends StreamTransformerBase<WebSocketMessage, GraphQLResponse<T>> { + /// request for this stream, needed to properly decode response events final GraphQLRequest<T> request; + + /// logs complete messages to better provide visibility to cancels final AmplifyLogger logger; + + /// executes when start_ack message received final void Function()? onEstablished; + /// [request] is used to properly decode response events + /// [onEstablished] is executed when start_ack message received + /// [logger] logs cancel messages when complete message received const WebSocketSubscriptionStreamTransformer( this.request, this.onEstablished, { diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart index 961a433fe4..1a46012387 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart @@ -14,12 +14,14 @@ // ignore_for_file: public_member_api_docs +@internal +library amplify_api.graphql.ws.web_socket_types; + import 'dart:convert'; import 'package:amplify_core/amplify_core.dart'; import 'package:meta/meta.dart'; -@internal class MessageType { final String type; @@ -57,7 +59,6 @@ class MessageType { } @immutable -@internal abstract class WebSocketMessagePayload { const WebSocketMessagePayload(); @@ -95,7 +96,6 @@ class ConnectionAckMessagePayload extends WebSocketMessagePayload { }; } -@internal class SubscriptionRegistrationPayload extends WebSocketMessagePayload { final GraphQLRequest request; final AWSApiConfig config; @@ -119,7 +119,6 @@ class SubscriptionRegistrationPayload extends WebSocketMessagePayload { } } -@internal class SubscriptionDataPayload extends WebSocketMessagePayload { final Map<String, dynamic>? data; final Map<String, dynamic>? errors; @@ -142,7 +141,6 @@ class SubscriptionDataPayload extends WebSocketMessagePayload { }; } -@internal class WebSocketError extends WebSocketMessagePayload implements Exception { final List<Map> errors; @@ -160,7 +158,6 @@ class WebSocketError extends WebSocketMessagePayload implements Exception { } @immutable -@internal class WebSocketMessage { final String? id; final MessageType messageType; @@ -208,13 +205,11 @@ class WebSocketMessage { } } -@internal class WebSocketConnectionInitMessage extends WebSocketMessage { WebSocketConnectionInitMessage() : super(messageType: MessageType.connectionInit); } -@internal class WebSocketSubscriptionRegistrationMessage extends WebSocketMessage { WebSocketSubscriptionRegistrationMessage({ required String id, @@ -222,7 +217,6 @@ class WebSocketSubscriptionRegistrationMessage extends WebSocketMessage { }) : super(messageType: MessageType.start, payload: payload, id: id); } -@internal class WebSocketStopMessage extends WebSocketMessage { WebSocketStopMessage({required String id}) : super(messageType: MessageType.stop, id: id); diff --git a/packages/api/amplify_api/test/dart_graphql_test.dart b/packages/api/amplify_api/test/dart_graphql_test.dart index f1d7919bef..b37a2611f3 100644 --- a/packages/api/amplify_api/test/dart_graphql_test.dart +++ b/packages/api/amplify_api/test/dart_graphql_test.dart @@ -148,6 +148,30 @@ void main() { expect(res.errors, equals(null)); }); + test('Query returns proper response.data with dynamic type', () async { + String graphQLDocument = ''' query TestQuery { + listBlogs { + items { + id + name + createdAt + } + } + } '''; + final req = GraphQLRequest<dynamic>( + document: graphQLDocument, + variables: {}, + ); + + final operation = Amplify.API.query(request: req); + final res = await operation.value; + + final expected = json.encode(_expectedQuerySuccessResponseBody['data']); + + expect(res.data, equals(expected)); + expect(res.errors, equals(null)); + }); + test('Mutate returns proper response.data', () async { String graphQLDocument = ''' mutation TestMutate(\$name: String!) { createBlog(input: {name: \$name}) { @@ -226,21 +250,20 @@ void main() { test('subscribe() should decode model data', () async { Completer<void> establishedCompleter = Completer(); - Completer<Post> dataCompleter = Completer(); final subscriptionRequest = ModelSubscriptions.onCreate(Post.classType); final subscription = Amplify.API.subscribe( subscriptionRequest, onEstablished: () => establishedCompleter.complete(), ); - - final streamSub = subscription.listen( - (event) => dataCompleter.complete(event.data), + await establishedCompleter.future; + + late StreamSubscription<GraphQLResponse<Post>> streamSub; + streamSub = subscription.listen( + expectAsync1((event) { + expect(event.data, isA<Post>()); + streamSub.cancel(); + }), ); - await expectLater(establishedCompleter.future, completes); - - final subscriptionData = await dataCompleter.future; - expect(subscriptionData, isA<Post>()); - streamSub.cancel(); }); }); diff --git a/packages/api/amplify_api/test/ws/web_socket_connection_test.dart b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart index 632b840781..9a1e3e6545 100644 --- a/packages/api/amplify_api/test/ws/web_socket_connection_test.dart +++ b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart @@ -59,13 +59,8 @@ void main() { test('subscribe() should initialize the connection and call onEstablished', () async { - Completer<void> establishedCompleter = Completer(); - connection.subscribe(subscriptionRequest, () { - establishedCompleter.complete(); - }); - + connection.subscribe(subscriptionRequest, expectAsync0(() {})); expectLater(connection.ready, completes); - expectLater(establishedCompleter.future, completes); }); test( @@ -88,19 +83,18 @@ void main() { }); test('subscribe() should return a subscription stream', () async { - Completer<String> dataCompleter = Completer(); final subscription = connection.subscribe( subscriptionRequest, null, ); - final streamSub = subscription.listen( - (event) => dataCompleter.complete(event.data), + late StreamSubscription<GraphQLResponse<String>> streamSub; + streamSub = subscription.listen( + expectAsync1((event) { + expect(event.data, json.encode(mockSubscriptionData)); + streamSub.cancel(); + }), ); - - final subscriptionData = await dataCompleter.future; - expect(subscriptionData, json.encode(mockSubscriptionData)); - streamSub.cancel(); }); test('cancel() should send a stop message', () async { From b6358fc842fa1cde6a8ff16acfcfe5b359c32958 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Mon, 22 Aug 2022 12:47:03 -0700 Subject: [PATCH 31/33] change null safety --- packages/api/amplify_api/example/ios/Runner/Info.plist | 2 ++ .../lib/src/decorators/web_socket_auth_utils.dart | 2 +- .../lib/src/graphql/ws/web_socket_connection.dart | 6 +----- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/api/amplify_api/example/ios/Runner/Info.plist b/packages/api/amplify_api/example/ios/Runner/Info.plist index 7c583a6a81..a41b727111 100644 --- a/packages/api/amplify_api/example/ios/Runner/Info.plist +++ b/packages/api/amplify_api/example/ios/Runner/Info.plist @@ -41,5 +41,7 @@ </array> <key>UIViewControllerBasedStatusBarAppearance</key> <false/> + <key>CADisableMinimumFrameDurationOnPhone</key> + <true/> </dict> </plist> diff --git a/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart b/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart index 1a6b063588..d1520c731e 100644 --- a/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart +++ b/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart @@ -106,7 +106,7 @@ Future<Map<String, String>> _generateAuthorizationHeaders( // // The canonical request URL is a little different depending on if authorizing // connection URI or start message (subscription registration). - final maybeConnect = isConnectionInit ? '' : '/connect'; + final maybeConnect = isConnectionInit ? '/connect' : ''; final canonicalHttpRequest = http.Request('POST', Uri.parse('${config.endpoint}$maybeConnect')); canonicalHttpRequest.headers.addAll(_requiredHeaders); diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart index bb21665953..939aab96b8 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart @@ -198,10 +198,6 @@ class WebSocketConnection implements Closeable { @visibleForTesting void send(WebSocketMessage message) { final msgJson = json.encode(message.toJson()); - if (_channel == null) { - throw ApiException( - 'Web socket not connected. Cannot send message $message'); - } _channel!.sink.add(msgJson); } @@ -257,6 +253,6 @@ class WebSocketConnection implements Closeable { // Re-broadcast other message types related to single subscriptions. - if (!_rebroadcastController.isClosed) _rebroadcastController.add(message); + _rebroadcastController.add(message); } } From d09da9a160e72c8d1e756635d987b8e1d5aa4776 Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Tue, 23 Aug 2022 14:10:03 -0700 Subject: [PATCH 32/33] undo plist change --- packages/api/amplify_api/example/ios/Runner/Info.plist | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/api/amplify_api/example/ios/Runner/Info.plist b/packages/api/amplify_api/example/ios/Runner/Info.plist index a41b727111..7c583a6a81 100644 --- a/packages/api/amplify_api/example/ios/Runner/Info.plist +++ b/packages/api/amplify_api/example/ios/Runner/Info.plist @@ -41,7 +41,5 @@ </array> <key>UIViewControllerBasedStatusBarAppearance</key> <false/> - <key>CADisableMinimumFrameDurationOnPhone</key> - <true/> </dict> </plist> From 6e384c3c4e57a5109af2742e2eacfe00397d2ecf Mon Sep 17 00:00:00 2001 From: Travis Sheppard <tshepp@amazon.com> Date: Tue, 23 Aug 2022 14:58:12 -0700 Subject: [PATCH 33/33] make enum messageType --- ...web_socket_message_stream_transformer.dart | 2 + .../lib/src/graphql/ws/web_socket_types.dart | 67 ++++++++++--------- packages/api/amplify_api/pubspec.yaml | 1 + 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart index 960a617eac..e037bd1ba5 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart @@ -86,6 +86,8 @@ class WebSocketSubscriptionStreamTransformer<T> case MessageType.complete: logger.info('Cancel succeeded for Operation: ${event.id}'); return; + default: + break; } } } diff --git a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart index 1a46012387..c957b82641 100644 --- a/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart @@ -20,42 +20,47 @@ library amplify_api.graphql.ws.web_socket_types; import 'dart:convert'; import 'package:amplify_core/amplify_core.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; -class MessageType { +enum MessageType { + @JsonValue('connection_init') + connectionInit('connection_init'), + + @JsonValue('connection_ack') + connectionAck('connection_ack'), + + @JsonValue('connection_error') + connectionError('connection_error'), + + @JsonValue('start') + start('start'), + + @JsonValue('start_ack') + startAck('start_ack'), + + @JsonValue('connection_error') + error('connection_error'), + + @JsonValue('data') + data('data'), + + @JsonValue('stop') + stop('stop'), + + @JsonValue('ka') + keepAlive('ka'), + + @JsonValue('complete') + complete('complete'); + final String type; - const MessageType._(this.type); - - factory MessageType.fromJson(dynamic json) => - values.firstWhere((el) => json == el.type); - - static const List<MessageType> values = [ - connectionInit, - connectionAck, - connectionError, - start, - startAck, - error, - data, - stop, - keepAlive, - complete, - ]; - - static const connectionInit = MessageType._('connection_init'); - static const connectionAck = MessageType._('connection_ack'); - static const connectionError = MessageType._('connection_error'); - static const error = MessageType._('error'); - static const start = MessageType._('start'); - static const startAck = MessageType._('start_ack'); - static const data = MessageType._('data'); - static const stop = MessageType._('stop'); - static const keepAlive = MessageType._('ka'); - static const complete = MessageType._('complete'); + const MessageType(this.type); - @override - String toString() => type; + factory MessageType.fromJson(dynamic json) { + return MessageType.values.firstWhere((el) => json == el.type); + } } @immutable diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index 2142e41417..aa5240a437 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: flutter: sdk: flutter http: ^0.13.4 + json_annotation: ^4.6.0 meta: ^1.7.0 plugin_platform_interface: ^2.0.0 web_socket_channel: ^2.2.0