From 110672a7c517bb15940cb719df35d38a0c6a54ea Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Wed, 15 Jun 2022 11:35:34 -0800 Subject: [PATCH 01/47] 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 { Category get category => Category.api; // ====== GraphQL ======= - GraphQLOperation query({required GraphQLRequest request}) { - return plugins.length == 1 - ? plugins[0].query(request: request) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation> query( + {required GraphQLRequest request}) => + defaultPlugin.query(request: request); - GraphQLOperation mutate({required GraphQLRequest request}) { - return plugins.length == 1 - ? plugins[0].mutate(request: request) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation> mutate( + {required GraphQLRequest 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 { Stream> subscribe( GraphQLRequest 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 delete( + String path, { + Map? headers, + HttpPayload? body, + Map? 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 get( + String path, { + Map? headers, + Map? 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 head( + String path, { + Map? headers, + Map? 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 patch( + String path, { + Map? headers, + HttpPayload? body, + Map? 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 post( + String path, { + Map? headers, + HttpPayload? body, + Map? 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 put( + String path, { + Map? headers, + HttpPayload? body, + Map? 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 query({required GraphQLRequest request}) { + CancelableOperation> query( + {required GraphQLRequest request}) { throw UnimplementedError('query() has not been implemented.'); } - GraphQLOperation mutate({required GraphQLRequest request}) { + CancelableOperation> mutate( + {required GraphQLRequest 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 delete( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 get( + String path, { + Map? headers, + Map? queryParameters, + String? apiName, + }) { + throw UnimplementedError('get() has not been implemented'); } - RestOperation post({required RestOptions restOptions}) { - throw UnimplementedError('post has not been implemented.'); + CancelableOperation head( + String path, { + Map? headers, + Map? queryParameters, + String? apiName, + }) { + throw UnimplementedError('head() has not been implemented'); } - RestOperation delete({required RestOptions restOptions}) { - throw UnimplementedError('delete has not been implemented.'); + CancelableOperation patch( + String path, { + HttpPayload? body, + Map? headers, + Map? queryParameters, + String? apiName, + }) { + throw UnimplementedError('patch() has not been implemented'); } - RestOperation head({required RestOptions restOptions}) { - throw UnimplementedError('head has not been implemented.'); + CancelableOperation post( + String path, { + HttpPayload? body, + Map? headers, + Map? queryParameters, + String? apiName, + }) { + throw UnimplementedError('post() has not been implemented'); } - RestOperation patch({required RestOptions restOptions}) { - throw UnimplementedError('patch has not been implemented.'); + CancelableOperation put( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 4d8dae1e20..004711395e 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,24 +19,16 @@ 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( super.message, { super.recoverySuggestion, super.underlyingException, - this.httpStatusCode, }); /// Constructor for down casting an AmplifyException to this exception ApiException._private( AmplifyException exception, - this.httpStatusCode, ) : super( exception.message, recoverySuggestion: exception.recoverySuggestion, @@ -53,7 +45,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 { - final Function cancel; - final Future> 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 on CancelableOperation> { + @Deprecated('use .value instead') + Future> 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> { + 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) { + return HttpPayload.bytes(body); + } + if (body is Stream>) { + return HttpPayload.streaming(body); + } + if (body is Map) { + 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 body) : super(Stream.value(body)); + + /// A form-encoded body of `key=value` pairs. + HttpPayload.formFields(Map 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> 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 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 response; - - const RestOperation({required this.response, required this.cancel}); +/// Allows callers to synchronously get unstreamed response with the decoded body. +extension RestOperation on CancelableOperation { + Future 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 { - /// The response status code. - final int statusCode; - - /// The response headers. - /// - /// Will be `null` if unavailable from the platform. - final Map? 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 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 { String _result = ''; void Function()? _unsubscribe; - late GraphQLOperation _lastOperation; + late CancelableOperation _lastOperation; Future 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 { } 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 { late TextEditingController _apiPathController; - late RestOperation _lastRestOperation; + late CancelableOperation _lastRestOperation; @override void initState() { @@ -39,18 +38,16 @@ class _RestApiViewState extends State { 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 { 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 { 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 { 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 { 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 { 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 query({required GraphQLRequest request}) { - Future> response = + CancelableOperation> query( + {required GraphQLRequest request}) { + Future> responseFuture = _getMethodChannelResponse(methodName: 'query', request: request); - - //TODO: Cancel implementation will be added along with REST API as it is shared - GraphQLOperation result = GraphQLOperation( - cancel: () => cancelRequest(request.id), - response: response, - ); - - return result; + return CancelableOperation.fromFuture(responseFuture); } @override - GraphQLOperation mutate({required GraphQLRequest request}) { - Future> response = + CancelableOperation> mutate( + {required GraphQLRequest request}) { + Future> responseFuture = _getMethodChannelResponse(methodName: 'mutate', request: request); - - //TODO: Cancel implementation will be added along with REST API as it is shared - GraphQLOperation result = GraphQLOperation( - 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 futureResponse = - _callNativeRestMethod(methodName, cancelToken, restOptions); + Future _restResponseHelper({ + required String methodName, + required String path, + required String cancelToken, + HttpPayload? body, + Map? headers, + Map? queryParameters, + String? apiName, + }) async { + Uint8List? bodyBytes; + if (body != null) { + final completer = Completer(); + 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 _restFunctionHelper({ + required String methodName, + required String path, + HttpPayload? body, + Map? headers, + Map? 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 _callNativeRestMethod( + Future _callNativeRestMethod( String methodName, String cancelToken, RestOptions restOptions) async { // Prepare map input Map inputsMap = {}; @@ -284,55 +325,125 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { } } - bool _shouldThrow(int statusCode) { - return statusCode < 200 || statusCode > 299; - } - - RestResponse _formatRestResponse(Map res) { + AWSStreamedHttpResponse _formatRestResponse(Map res) { final statusCode = res['statusCode'] as int; - final headers = res['headers'] as Map?; - final response = RestResponse( - data: res['data'] as Uint8List?, - headers: headers?.cast(), - 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(); + 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 get( + String path, { + Map? headers, + Map? 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 put( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 post( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 delete( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 head( + String path, { + Map? headers, + Map? 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 patch( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 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()); +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 _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 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 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 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 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 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), '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 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), '{"foo":"bar"}'); return { - 'statusCode': statusCode, - 'headers': {}, - '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 = 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 { 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 66d99bf3d9ecd270f6b5b4ffadfc1af67839787c Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Thu, 23 Jun 2022 11:39:46 -0500 Subject: [PATCH 02/47] 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 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 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 _authProviders = {}; + + /// {@macro amplify_api.amplify_api_dart} + AmplifyAPIDart({ + List authProviders = const [], + this.modelProvider, + }) : super.protected() { + authProviders.forEach(registerAuthProvider); + } + + @override + Future 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 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 codec = _NativeApiBridgeCodec(); + + Future addPlugin(List arg_authProvidersList) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.NativeApiBridge.addPlugin', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_authProvidersList]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + 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 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 = - (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) { + 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 authProvidersList); + + /** The codec used by NativeApiBridge. */ + static MessageCodec getCodec() { + return NativeApiBridgeCodec.INSTANCE; + } + + /** Sets up an instance of `NativeApiBridge` to handle messages through the `binaryMessenger`. */ + static void setup(BinaryMessenger binaryMessenger, NativeApiBridge api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.NativeApiBridge.addPlugin", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + List authProvidersListArg = (List)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 wrapError(Throwable exception) { + Map 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 +@protocol FlutterBinaryMessenger; +@protocol FlutterMessageCodec; +@class FlutterError; +@class FlutterStandardTypedData; + +NS_ASSUME_NONNULL_BEGIN + + +/// The codec used by NativeApiBridge. +NSObject *NativeApiBridgeGetCodec(void); + +@protocol NativeApiBridge +- (void)addPluginAuthProvidersList:(NSArray *)authProvidersList error:(FlutterError *_Nullable *_Nonnull)error; +@end + +extern void NativeApiBridgeSetup(id binaryMessenger, NSObject *_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 + +#if !__has_feature(objc_arc) +#error File requires ARC to be enabled. +#endif + +static NSDictionary *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 *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 binaryMessenger, NSObject *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 *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) { 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 2ff8e51b9d4c2d6be671b9b2bb204f550dec239d Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Mon, 27 Jun 2022 11:43:25 -0500 Subject: [PATCH 03/47] 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 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) { + override fun addPlugin( + authProvidersList: MutableList, + result: NativeApiPluginBindings.Result + ) { 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 { + 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 authProvidersList); + void addPlugin(@NonNull List authProvidersList, Result result); /** The codec used by NativeApiBridge. */ static MessageCodec 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 resultCallback = new Result() { + 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 @protocol FlutterBinaryMessenger; @@ -27,7 +27,7 @@ NS_ASSUME_NONNULL_BEGIN NSObject *NativeApiBridgeGetCodec(void); @protocol NativeApiBridge -- (void)addPluginAuthProvidersList:(NSArray *)authProvidersList error:(FlutterError *_Nullable *_Nonnull)error; +- (void)addPluginAuthProvidersList:(NSArray *)authProvidersList completion:(void(^)(FlutterError *_Nullable))completion; @end extern void NativeApiBridgeSetup(id binaryMessenger, NSObject *_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 @@ -86,13 +86,13 @@ void NativeApiBridgeSetup(id binaryMessenger, NSObject *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) { + 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 f4211af81022a08d7a1175b6084c32eb74de7196 Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Mon, 27 Jun 2022 14:28:39 -0800 Subject: [PATCH 04/47] 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 72e5923006..e34408b8e9 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 { + const EndpointConfig(this.name, this.config); + + final String name; + final AWSApiConfig config; + + @override + List 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? 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 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 _clientPool = {}; /// The registered [APIAuthProvider] instances. final Map _authProviders = {}; @@ -33,8 +45,10 @@ class AmplifyAPIDart extends AmplifyAPI { /// {@macro amplify_api.amplify_api_dart} AmplifyAPIDart({ List 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? 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 _makeCancelable(Future responseFuture) { + return CancelableOperation.fromFuture(responseFuture); + } + + CancelableOperation _prepareRestResponse( + Future 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 delete( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 get( + String path, { + Map? headers, + Map? 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 head( + String path, { + Map? headers, + Map? 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 patch( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 post( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 put( + String path, { + HttpPayload? body, + Map? headers, + Map? 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? addContentTypeToHeaders( + Map? 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.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 _verifyRestOperation( + CancelableOperation 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 5608b598b04834fa3fb2ad51718fa3afb80134a9 Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Wed, 13 Jul 2022 15:27:11 -0500 Subject: [PATCH 05/47] 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 { /// 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 errors; + /// A list of errors from execution. If no errors, it will be `null`. + final List? errors; const GraphQLResponse({ this.data, @@ -36,7 +36,10 @@ class GraphQLResponse { }) { 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( document: graphQLDocument, variables: {'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( 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 { /// 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? queryParameters) { + Uri getUri({String? path, Map? 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? 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> query( + {required GraphQLRequest request}) { + final graphQLClient = getGraphQLClient(apiName: request.apiName); + final uri = _getGraphQLUri(request.apiName); + + final responseFuture = sendGraphQLRequest( + request: request, client: graphQLClient, uri: uri); + return _makeCancelable>(responseFuture); + } + + @override + CancelableOperation> mutate( + {required GraphQLRequest request}) { + final graphQLClient = getGraphQLClient(apiName: request.apiName); + final uri = _getGraphQLUri(request.apiName); + + final responseFuture = sendGraphQLRequest( + request: request, client: graphQLClient, uri: uri); + return _makeCancelable>(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 decode( {required GraphQLRequest request, String? data, - required List errors}) { + List? 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> sendGraphQLRequest({ + required GraphQLRequest 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) { + 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(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 response = GraphQLResponseDecoder.instance.decode( request: request, data: result['data'] as String?, errors: errors); @@ -466,19 +466,4 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { ); } } - - List _deserializeGraphQLResponseErrors( - Map response, - ) { - final errors = response['errors'] as List?; - if (errors == null || errors.isEmpty) { - return const []; - } - return errors - .cast() - .map((message) => GraphQLResponseError.fromJson( - message.cast(), - )) - .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? 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? deserializeGraphQLResponseErrors( + Map response, +) { + final errors = response['errors'] as List?; + if (errors == null || errors.isEmpty) { + return null; + } + return errors + .cast() + .map((message) => GraphQLResponseError.fromJson( + message.cast(), + )) + .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 req = + ModelQueries.get(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()); + 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: [..._errorPath], + extensions: {..._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 985b04591de0c36c22c27b01b8de1b2db11a7295 Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Tue, 19 Jul 2022 08:42:49 -0800 Subject: [PATCH 06/47] 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 e34408b8e9..2687e18fe0 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 addPlugin() async {} /// Configures the plugin using the registered [config]. - Future configure({AmplifyConfig? config}) async {} + Future 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 { /// For public APIs. @JsonValue('NONE') - none, + none(AmplifyAuthProviderToken()), /// 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()), /// 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()), /// 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()), /// 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()), /// 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()); + + const APIAuthorizationType(this.authProviderToken); + final AmplifyAuthProviderToken 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 extends Token { + 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 authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }); +} + +abstract class AWSIamAmplifyAuthProvider extends AmplifyAuthProvider + implements AWSCredentialsProvider { + @override + Future authorizeRequest( + AWSBaseHttpRequest request, { + covariant IamAuthProviderOptions options, + }); +} + +abstract class TokenAmplifyAuthProvider extends AmplifyAuthProvider { + Future getLatestAuthToken(); + + @override + Future authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }) async { + final token = await getLatestAuthToken(); + request.headers.putIfAbsent(AWSHeaders.authorization, () => token); + return request; + } +} + +class AmplifyAuthProviderRepository { + final Map _authProviders = {}; + + T? getAuthProvider( + AmplifyAuthProviderToken token) { + return _authProviders[token] as T?; + } + + void registerAuthProvider( + AmplifyAuthProviderToken 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 authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }) async { + request.headers.putIfAbsent(_testAuthKey, () => 'foo'); + return request; + } +} + +class SecondTestAuthProvider extends AmplifyAuthProvider { + @override + Future authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }) async { + request.headers.putIfAbsent(_testAuthKey, () => 'bar'); + return request; + } +} + +class TestAWSCredentialsAuthProvider extends AWSIamAmplifyAuthProvider { + @override + Future retrieve() async { + return const AWSCredentials( + 'fake-access-key-123', 'fake-secret-access-key-456'); + } + + @override + Future authorizeRequest( + AWSBaseHttpRequest request, { + covariant IamAuthProviderOptions? options, + }) async { + request.headers.putIfAbsent(_testAuthKey, () => 'foo'); + return request as AWSSignedRequest; + } +} + +class TestTokenProvider extends TokenAmplifyAuthProvider { + @override + Future 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(); + authRepo.registerAuthProvider(providerKey, credentialAuthProvider); + AWSIamAmplifyAuthProvider? actualAuthProvider = + authRepo.getAuthProvider(providerKey); + expect(actualAuthProvider, isA()); + }); + + 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 configure({AmplifyConfig? config}) async { + Future 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 configure({AmplifyConfig? config}) async { + Future 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 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 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 _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 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()); + }); + }); + + 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()); + expect(credentials.secretAccessKey, isA()); + }); + + 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()), + ); + }); + }); +} 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 hubEventsController; late Stream hubEvents; + final testAuthRepo = AmplifyAuthProviderRepository(); + final userDeletedEvent = isA().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(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 hubEventsController; late Stream hubEvents; + final testAuthRepo = AmplifyAuthProviderRepository(); + final emitsSignOutEvent = emitsThrough( isA().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 7b6ce8ea9d6a00c4aa88c341b8ac0cfe060a761b Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Thu, 21 Jul 2022 12:50:37 -0800 Subject: [PATCH 07/47] 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 { /// See also: /// - [API Key Authorization](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#api-key-authorization) @JsonValue('API_KEY') - apiKey(AmplifyAuthProviderToken()), + apiKey(AmplifyAuthProviderToken()), /// 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 authorizeRequest( AWSBaseHttpRequest request, { @@ -50,6 +56,14 @@ abstract class AWSIamAmplifyAuthProvider extends AmplifyAuthProvider }); } +abstract class ApiKeyAmplifyAuthProvider extends AmplifyAuthProvider { + @override + Future authorizeRequest( + AWSBaseHttpRequest request, { + covariant ApiKeyAuthProviderOptions? options, + }); +} + abstract class TokenAmplifyAuthProvider extends AmplifyAuthProvider { Future 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 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 _clientPool = {}; + final Map _clientPool = {}; /// The registered [APIAuthProvider] instances. final Map _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> query( {required GraphQLRequest request}) { - final graphQLClient = getGraphQLClient(apiName: request.apiName); + final graphQLClient = + getHttpClient(EndpointType.graphQL, apiName: request.apiName); final uri = _getGraphQLUri(request.apiName); final responseFuture = sendGraphQLRequest( @@ -171,7 +178,8 @@ class AmplifyAPIDart extends AmplifyAPI { @override CancelableOperation> mutate( {required GraphQLRequest request}) { - final graphQLClient = getGraphQLClient(apiName: request.apiName); + final graphQLClient = + getHttpClient(EndpointType.graphQL, apiName: request.apiName); final uri = _getGraphQLUri(request.apiName); final responseFuture = sendGraphQLRequest( @@ -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 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? 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 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())); + }); + + 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())); + }); + }); +} 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()); + 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); + + 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()); + }); + + 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(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 retrieve() async { + return const AWSCredentials( + 'fake-access-key-123', 'fake-secret-access-key-456'); + } + + @override + Future 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 cb6fbdb7dd5f74dd10e3c695fc16115927f5c89a Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Fri, 29 Jul 2022 09:31:47 -0500 Subject: [PATCH 08/47] 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 { /// 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? 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 { {this.apiName, required this.document, this.variables = const {}, + this.headers, this.decodePath, this.modelType}); Map serializeAsMap() => { '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> sendGraphQLRequest({ }) 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 a1e8d9d241e100c2e976412791395241401547de Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Mon, 8 Aug 2022 08:58:01 -0800 Subject: [PATCH 09/47] 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 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())); }); - 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 retrieve() async { @@ -43,6 +45,13 @@ class TestIamAuthProvider extends AWSIamAmplifyAuthProvider { } } +class TestTokenAuthProvider extends TokenAmplifyAuthProvider { + @override + Future 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 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 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()); }); - }); - - 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()); - expect(credentials.secretAccessKey, isA()); + test('registers CognitoUserPoolsAuthProvider', () async { + final authProvider = testAuthRepo.getAuthProvider( + APIAuthorizationType.userPools.authProviderToken, + ); + expect(authProvider, isA()); }); + }); - 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()), ); }); - 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()), + throwsA(isA()), ); }); }); + + 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()); + expect(credentials.secretAccessKey, isA()); + }); + + 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()), + ); + }); + }); + + 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()), + ); + }); + }); + + 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 3ac59f4ce24ba0ad98fc4f1dcde8a4fc3575c856 Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Mon, 22 Aug 2022 09:05:41 -0800 Subject: [PATCH 10/47] 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 4feb3e3797..4e400ff030 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 configure({AmplifyConfig? config}) async { - await super.configure(config: config); + Future 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(); 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 4009458ad1a03d2b5ff2909b70d5b788fbf8ff0d Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Fri, 26 Aug 2022 08:13:39 -0800 Subject: [PATCH 11/47] feat(api): .subscribe() for GraphQL (#1915) Co-authored-by: Elijah Quartey --- .../integration_test/graphql_tests.dart | 234 ++++++++-------- .../example/lib/graphql_api_view.dart | 20 +- .../amplify_api/lib/src/api_plugin_impl.dart | 33 +++ .../src/decorators/web_socket_auth_utils.dart | 125 +++++++++ .../src/graphql/ws/web_socket_connection.dart | 258 ++++++++++++++++++ ...web_socket_message_stream_transformer.dart | 94 +++++++ .../lib/src/graphql/ws/web_socket_types.dart | 228 ++++++++++++++++ packages/api/amplify_api/pubspec.yaml | 3 + .../amplify_api/test/dart_graphql_test.dart | 96 ++++++- packages/api/amplify_api/test/util.dart | 115 ++++++++ .../test/ws/web_socket_auth_utils_test.dart | 85 ++++++ .../test/ws/web_socket_connection_test.dart | 111 ++++++++ 12 files changed, 1275 insertions(+), 127 deletions(-) create mode 100644 packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart create mode 100644 packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart create mode 100644 packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart create mode 100644 packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart create mode 100644 packages/api/amplify_api/test/ws/web_socket_auth_utils_test.dart create mode 100644 packages/api/amplify_api/test/ws/web_socket_connection_test.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>> - _getEstablishedSubscriptionOperation( - GraphQLRequest subscriptionRequest, - void Function(GraphQLResponse) onData) async { - Completer establishedCompleter = Completer(); - final stream = - Amplify.API.subscribe(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>> + _getEstablishedSubscriptionOperation( + GraphQLRequest subscriptionRequest, + void Function(GraphQLResponse) onData) async { + Completer establishedCompleter = Completer(); + final stream = + Amplify.API.subscribe(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> _establishSubscriptionAndMutate( - GraphQLRequest subscriptionRequest, - Future Function() mutationFunction) async { - Completer> dataCompleter = Completer(); - // With stream established, exec callback with stream events. - final subscription = await _getEstablishedSubscriptionOperation( - 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> _establishSubscriptionAndMutate( + GraphQLRequest subscriptionRequest, + Future Function() mutationFunction) async { + Completer> dataCompleter = Completer(); + // With stream established, exec callback with stream events. + final subscription = await _getEstablishedSubscriptionOperation( + 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(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.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(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.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/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 { 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 query() async { 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..66e0d0ca91 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/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'; @@ -37,11 +38,16 @@ 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. final Map _clientPool = {}; + /// A map of the keys from the Amplify API config websocket connections to use + /// for that endpoint. + final Map _webSocketConnectionPool = {}; + /// The registered [APIAuthProvider] instances. final Map _authProviders = {}; @@ -123,6 +129,24 @@ 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, + logger: _logger.createChild( + 'webSocketConnection${endpoint.name}', + ), + ); + } + Uri _getGraphQLUri(String? apiName) { final endpoint = _apiConfig.getEndpoint( type: EndpointType.graphQL, @@ -187,6 +211,15 @@ class AmplifyAPIDart extends AmplifyAPI { return _makeCancelable>(responseFuture); } + @override + Stream> subscribe( + GraphQLRequest request, { + void Function()? onEstablished, + }) { + return getWebSocketConnection(apiName: request.apiName) + .subscribe(request, onEstablished); + } + // ====== REST ======= @override 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 new file mode 100644 index 0000000000..d1520c731e --- /dev/null +++ b/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart @@ -0,0 +1,125 @@ +// 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. + +@internal +library amplify_api.decorators.web_socket_auth_utils; + +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/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 _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 = '{}'; + +/// 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= +Future generateConnectionUri( + AWSApiConfig config, AmplifyAuthProviderRepository authRepo) async { + final authorizationHeaders = await _generateAuthorizationHeaders( + config, + isConnectionInit: true, + authRepo: authRepo, + body: _emptyBody, + ); + 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: { + 'header': encodedAuthHeaders, + 'payload': base64.encode(utf8.encode(_emptyBody)), + }); +} + +/// 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 +Future + 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, + isConnectionInit: false, + 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 `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> _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 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(_requiredHeaders); + 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/web_socket_connection.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart new file mode 100644 index 0000000000..939aab96b8 --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart @@ -0,0 +1,258 @@ +// 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/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.ws.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 AmplifyLogger _logger; + + // 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. + WebSocketChannel? _channel; + StreamSubscription? _subscription; + RestartableTimer? _timeoutTimer; + + // Re-broadcasts incoming messages for child streams (single GraphQL subscriptions). + // start_ack, data, error + final StreamController _rebroadcastController = + StreamController.broadcast(); + Stream get _messageStream => _rebroadcastController.stream; + + // Manage initial connection state. + var _initMemo = AsyncMemoizer(); + Completer _connectionReady = Completer(); + + /// Fires when the connection is ready to be listened to after the first + /// `connection_ack` message. + Future get ready => _connectionReady.future; + + /// {@macro amplify_api.ws.web_socket_connection} + WebSocketConnection(this._config, this._authProviderRepo, + {required AmplifyLogger logger}) + : _logger = logger; + + /// Connects _subscription stream to _onData handler. + @visibleForTesting + StreamSubscription getStreamSubscription( + Stream stream) { + 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 + /// init message. + @visibleForTesting + Future connect(Uri connectionUri) async { + _channel = WebSocketChannel.connect( + connectionUri, + protocols: webSocketProtocols, + ); + _subscription = getStreamSubscription(_channel!.stream); + } + + void _connectionError(ApiException exception) { + _connectionReady.completeError(exception); + _channel?.sink.close(); + _resetConnectionInit(); + } + + // Reset connection init variables so it can be re-attempted. + void _resetConnectionInit() { + _initMemo = AsyncMemoizer(); + _connectionReady = Completer(); + } + + /// 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. + /// + /// Connects to WebSocket, sends connection message and resolves future once + /// connection_ack message received from server. If the connection was previously + /// established then will return previously completed future. + Future init() => _initMemo.runOnce(_init); + + Future _init() async { + final connectionUri = + await generateConnectionUri(_config, _authProviderRepo); + await connect(connectionUri); + + send(WebSocketConnectionInitMessage()); + + return ready; + } + + Future _sendSubscriptionRegistrationMessage( + GraphQLRequest 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> subscribe( + GraphQLRequest request, + void Function()? onEstablished, + ) { + // Create controller for this subscription so we can add errors. + late StreamController> controller; + controller = StreamController>.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. + _messageStream + .where((msg) => msg.id == request.id) + .transform(WebSocketSubscriptionStreamTransformer( + request, + onEstablished, + logger: _logger, + )) + .listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + cancelOnError: true, + ); + + _sendSubscriptionRegistrationMessage(request) + .catchError(controller.addError); + + return controller.stream; + } + + /// 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. + } + + /// Serializes a message as JSON string and sends over WebSocket channel. + @visibleForTesting + void send(WebSocketMessage message) { + final msgJson = json.encode(message.toJson()); + _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. + /// + /// Here, handle connection-wide messages and pass subscription events to + /// `_rebroadcastController`. + void _onData(WebSocketMessage message) { + _logger.verbose('websocket received message: ${prettyPrintJson(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(); + _logger.verbose('Connection established. Registered timer'); + return; + case MessageType.connectionError: + _connectionError(const ApiException( + 'Error occurred while connecting to the websocket')); + return; + case MessageType.keepAlive: + _timeoutTimer?.reset(); + _logger.verbose('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 other message types related to single subscriptions. + + _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 new file mode 100644 index 0000000000..e037bd1ba5 --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart @@ -0,0 +1,94 @@ +// 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. + +@internal +library amplify_api.graphql.ws.web_socket_message_stream_transformer; + +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'; + +/// Top-level transformer. +class WebSocketMessageStreamTransformer + extends StreamTransformerBase { + /// Transforms raw web socket response (String) to `WebSocketMessage` for all input + /// from channel. + const WebSocketMessageStreamTransformer(); + + @override + Stream bind(Stream stream) { + return stream.cast().map>((str) { + return json.decode(str) as Map; + }).map(WebSocketMessage.fromJson); + } +} + +/// Final level of transformation for converting `WebSocketMessage`s to stream +/// of `GraphQLResponse` that is eventually passed to public API `Amplify.API.subscribe`. +class WebSocketSubscriptionStreamTransformer + extends StreamTransformerBase> { + /// request for this stream, needed to properly decode response events + final GraphQLRequest 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, { + required this.logger, + }); + + @override + Stream> bind(Stream 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; + // TODO(ragingsquirrel3): refactor decoder + final errors = deserializeGraphQLResponseErrors(payload.toJson()); + yield GraphQLResponseDecoder.instance.decode( + 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; + 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 new file mode 100644 index 0000000000..c957b82641 --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart @@ -0,0 +1,228 @@ +// 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 + +@internal +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'; + +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) { + return MessageType.values.firstWhere((el) => json == el.type); + } +} + +@immutable +abstract class WebSocketMessagePayload { + const WebSocketMessagePayload(); + + static const 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 toJson(); + + @override + String toString() => prettyPrintJson(toJson()); +} + +@internal +class ConnectionAckMessagePayload extends WebSocketMessagePayload { + final int connectionTimeoutMs; + + const ConnectionAckMessagePayload(this.connectionTimeoutMs); + + static ConnectionAckMessagePayload fromJson(Map json) { + final connectionTimeoutMs = json['connectionTimeoutMs'] as int; + return ConnectionAckMessagePayload(connectionTimeoutMs); + } + + @override + Map toJson() => { + 'connectionTimeoutMs': connectionTimeoutMs, + }; +} + +class SubscriptionRegistrationPayload extends WebSocketMessagePayload { + final GraphQLRequest request; + final AWSApiConfig config; + final Map authorizationHeaders; + + const SubscriptionRegistrationPayload({ + required this.request, + required this.config, + required this.authorizationHeaders, + }); + + @override + Map toJson() { + return { + 'data': jsonEncode( + {'variables': request.variables, 'query': request.document}), + 'extensions': >{ + 'authorization': authorizationHeaders + } + }; + } +} + +class SubscriptionDataPayload extends WebSocketMessagePayload { + final Map? data; + final Map? errors; + + const 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(), + errors?.cast(), + ); + } + + @override + Map toJson() => { + 'data': data, + 'errors': errors, + }; +} + +class WebSocketError extends WebSocketMessagePayload implements Exception { + final List errors; + + const WebSocketError(this.errors); + + static WebSocketError fromJson(Map json) { + final errors = json['errors'] as List?; + return WebSocketError(errors?.cast() ?? []); + } + + @override + Map toJson() => { + 'errors': errors, + }; +} + +@immutable +class WebSocketMessage { + final String? id; + final MessageType messageType; + final WebSocketMessagePayload? payload; + + WebSocketMessage({ + String? id, + required this.messageType, + this.payload, + }) : id = id ?? uuid(); + + const 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 toJson() => { + if (id != null) 'id': id, + 'type': messageType.type, + if (payload != null) 'payload': payload?.toJson(), + }; + + @override + String toString() { + return prettyPrintJson(this); + } +} + +class WebSocketConnectionInitMessage extends WebSocketMessage { + WebSocketConnectionInitMessage() + : super(messageType: MessageType.connectionInit); +} + +class WebSocketSubscriptionRegistrationMessage extends WebSocketMessage { + WebSocketSubscriptionRegistrationMessage({ + required String id, + required SubscriptionRegistrationPayload payload, + }) : super(messageType: MessageType.start, payload: payload, id: id); +} + +class WebSocketStopMessage extends WebSocketMessage { + WebSocketStopMessage({required String id}) + : super(messageType: MessageType.stop, id: id); +} diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index a4b2121efe..aa5240a437 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -21,8 +21,11 @@ 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 + dev_dependencies: amplify_lints: diff --git a/packages/api/amplify_api/test/dart_graphql_test.dart b/packages/api/amplify_api/test/dart_graphql_test.dart index 4d9d8ec47f..b37a2611f3 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,34 @@ void main() { } } } '''; - final req = GraphQLRequest(document: graphQLDocument, variables: {}); + 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('Query returns proper response.data with dynamic type', () 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; @@ -147,8 +181,10 @@ void main() { } } '''; final graphQLVariables = {'name': 'Test Blog 1'}; - final req = GraphQLRequest( - document: graphQLDocument, variables: graphQLVariables); + final req = GraphQLRequest( + document: graphQLDocument, + variables: graphQLVariables, + ); final operation = Amplify.API.mutate(request: req); final res = await operation.value; @@ -158,6 +194,33 @@ void main() { expect(res.data, equals(expected)); expect(res.errors, equals(null)); }); + + test('subscribe() should return a subscription stream', () async { + Completer establishedCompleter = Completer(); + Completer dataCompleter = Completer(); + const graphQLDocument = '''subscription MySubscription { + onCreateBlog { + id + name + createdAt + } + }'''; + final subscriptionRequest = + GraphQLRequest(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 +247,33 @@ void main() { expect(res.data?.id, _modelQueryId); expect(res.errors, equals(null)); }); + + test('subscribe() should decode model data', () async { + Completer establishedCompleter = Completer(); + final subscriptionRequest = ModelSubscriptions.onCreate(Post.classType); + final subscription = Amplify.API.subscribe( + subscriptionRequest, + onEstablished: () => establishedCompleter.complete(), + ); + await establishedCompleter.future; + + late StreamSubscription> streamSub; + streamSub = subscription.listen( + expectAsync1((event) { + expect(event.data, isA()); + streamSub.cancel(); + }), + ); + }); }); group('Error Handling', () { test('response errors are decoded', () async { String graphQLDocument = ''' TestError '''; - final req = GraphQLRequest(document: graphQLDocument, variables: {}); + final req = GraphQLRequest( + document: graphQLDocument, + variables: {}, + ); final operation = Amplify.API.query(request: req); final res = await operation.value; @@ -209,7 +293,7 @@ void main() { }); test('canceled query request should never resolve', () async { - final req = GraphQLRequest(document: '', variables: {}); + final req = GraphQLRequest(document: '', variables: {}); final operation = Amplify.API.query(request: req); operation.cancel(); operation.then((p0) => fail('Request should have been cancelled.')); @@ -218,7 +302,7 @@ void main() { }); test('canceled mutation request should never resolve', () async { - final req = GraphQLRequest(document: '', variables: {}); + final req = GraphQLRequest(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 sentMessages = []; + + MockWebSocketConnection( + AWSApiConfig config, AmplifyAuthProviderRepository authProviderRepo) + : super(config, authProviderRepo, logger: AmplifyLogger()); + + WebSocketMessage? get lastSentMessage => sentMessages.lastOrNull; + + final messageStream = StreamController(); + + @override + Future 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 = []; + 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(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 new file mode 100644 index 0000000000..9a1e3e6545 --- /dev/null +++ b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart @@ -0,0 +1,111 @@ +// 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/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(); + + late MockWebSocketConnection connection; + + const graphQLDocument = '''subscription MySubscription { + onCreateBlog { + id + name + createdAt + } + }'''; + final subscriptionRequest = GraphQLRequest(document: graphQLDocument); + + setUp(() { + connection = MockWebSocketConnection( + testApiKeyConfig, + getTestAuthProviderRepo(), + ); + }); + + 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); + expect( + connection.connectedUri.toString(), + expectedApiKeyWebSocketConnectionUrl, + ); + expect( + connection.lastSentMessage?.messageType, MessageType.connectionInit); + }); + + test('subscribe() should initialize the connection and call onEstablished', + () async { + connection.subscribe(subscriptionRequest, expectAsync0(() {})); + expectLater(connection.ready, completes); + }); + + test( + 'subscribe() should send SubscriptionRegistrationMessage with authorized payload correctly serialized', + () async { + connection.init(); + await connection.ready; + Completer establishedCompleter = Completer(); + connection.subscribe(subscriptionRequest, () { + establishedCompleter.complete(); + }); + await establishedCompleter.future; + + final lastMessage = connection.lastSentMessage; + expect(lastMessage?.messageType, MessageType.start); + final payloadJson = lastMessage?.payload?.toJson(); + final apiKeyFromPayload = + payloadJson?['extensions']['authorization'][xApiKey]; + expect(apiKeyFromPayload, testApiKeyConfig.apiKey); + }); + + test('subscribe() should return a subscription stream', () async { + final subscription = connection.subscribe( + subscriptionRequest, + null, + ); + + late StreamSubscription> streamSub; + streamSub = subscription.listen( + expectAsync1((event) { + expect(event.data, json.encode(mockSubscriptionData)); + streamSub.cancel(); + }), + ); + }); + + test('cancel() should send a stop message', () async { + Completer 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); + }); + }); +} From 48c9582beefd8131aeb6d86cc49a446ca405c6d2 Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Thu, 1 Sep 2022 16:51:53 -0500 Subject: [PATCH 12/47] chore(api): Api Example - generate files for multi-plaform (#2080) --- packages/api/amplify_api/example/.metadata | 39 +- .../res/drawable-v21/launch_background.xml | 12 + .../app/src/main/res/values-night/styles.xml | 18 + .../api/amplify_api/example/linux/.gitignore | 1 + .../amplify_api/example/linux/CMakeLists.txt | 138 +++++ .../example/linux/flutter/CMakeLists.txt | 88 +++ .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 + .../linux/flutter/generated_plugins.cmake | 23 + .../api/amplify_api/example/linux/main.cc | 6 + .../example/linux/my_application.cc | 104 ++++ .../example/linux/my_application.h | 18 + .../api/amplify_api/example/macos/.gitignore | 7 + .../macos/Flutter/Flutter-Debug.xcconfig | 2 + .../macos/Flutter/Flutter-Release.xcconfig | 2 + .../Flutter/GeneratedPluginRegistrant.swift | 10 + .../api/amplify_api/example/macos/Podfile | 40 ++ .../macos/Runner.xcodeproj/project.pbxproj | 572 ++++++++++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 87 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../example/macos/Runner/AppDelegate.swift | 9 + .../AppIcon.appiconset/Contents.json | 68 +++ .../AppIcon.appiconset/app_icon_1024.png | Bin 0 -> 46993 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 0 -> 3276 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 0 -> 1429 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 0 -> 5933 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 0 -> 1243 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 0 -> 14800 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 0 -> 1874 bytes .../macos/Runner/Base.lproj/MainMenu.xib | 343 +++++++++++ .../macos/Runner/Configs/AppInfo.xcconfig | 14 + .../macos/Runner/Configs/Debug.xcconfig | 2 + .../macos/Runner/Configs/Release.xcconfig | 2 + .../macos/Runner/Configs/Warnings.xcconfig | 13 + .../macos/Runner/DebugProfile.entitlements | 12 + .../example/macos/Runner/Info.plist | 32 + .../macos/Runner/MainFlutterWindow.swift | 15 + .../example/macos/Runner/Release.entitlements | 8 + .../api/amplify_api/example/web/favicon.png | Bin 0 -> 917 bytes .../example/web/icons/Icon-192.png | Bin 0 -> 5292 bytes .../example/web/icons/Icon-512.png | Bin 0 -> 8252 bytes .../example/web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes .../example/web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes .../api/amplify_api/example/web/index.html | 58 ++ .../api/amplify_api/example/web/manifest.json | 35 ++ .../amplify_api/example/windows/.gitignore | 17 + .../example/windows/CMakeLists.txt | 101 ++++ .../example/windows/flutter/CMakeLists.txt | 104 ++++ .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 + .../windows/flutter/generated_plugins.cmake | 23 + .../example/windows/runner/CMakeLists.txt | 32 + .../example/windows/runner/Runner.rc | 121 ++++ .../example/windows/runner/flutter_window.cpp | 61 ++ .../example/windows/runner/flutter_window.h | 33 + .../example/windows/runner/main.cpp | 43 ++ .../example/windows/runner/resource.h | 16 + .../windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes .../windows/runner/runner.exe.manifest | 20 + .../example/windows/runner/utils.cpp | 64 ++ .../example/windows/runner/utils.h | 19 + .../example/windows/runner/win32_window.cpp | 245 ++++++++ .../example/windows/runner/win32_window.h | 98 +++ 65 files changed, 2848 insertions(+), 2 deletions(-) create mode 100644 packages/api/amplify_api/example/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 packages/api/amplify_api/example/android/app/src/main/res/values-night/styles.xml create mode 100644 packages/api/amplify_api/example/linux/.gitignore create mode 100644 packages/api/amplify_api/example/linux/CMakeLists.txt create mode 100644 packages/api/amplify_api/example/linux/flutter/CMakeLists.txt create mode 100644 packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.cc create mode 100644 packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.h create mode 100644 packages/api/amplify_api/example/linux/flutter/generated_plugins.cmake create mode 100644 packages/api/amplify_api/example/linux/main.cc create mode 100644 packages/api/amplify_api/example/linux/my_application.cc create mode 100644 packages/api/amplify_api/example/linux/my_application.h create mode 100644 packages/api/amplify_api/example/macos/.gitignore create mode 100644 packages/api/amplify_api/example/macos/Flutter/Flutter-Debug.xcconfig create mode 100644 packages/api/amplify_api/example/macos/Flutter/Flutter-Release.xcconfig create mode 100644 packages/api/amplify_api/example/macos/Flutter/GeneratedPluginRegistrant.swift create mode 100644 packages/api/amplify_api/example/macos/Podfile create mode 100644 packages/api/amplify_api/example/macos/Runner.xcodeproj/project.pbxproj create mode 100644 packages/api/amplify_api/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/api/amplify_api/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 packages/api/amplify_api/example/macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 packages/api/amplify_api/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/api/amplify_api/example/macos/Runner/AppDelegate.swift create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Base.lproj/MainMenu.xib create mode 100644 packages/api/amplify_api/example/macos/Runner/Configs/AppInfo.xcconfig create mode 100644 packages/api/amplify_api/example/macos/Runner/Configs/Debug.xcconfig create mode 100644 packages/api/amplify_api/example/macos/Runner/Configs/Release.xcconfig create mode 100644 packages/api/amplify_api/example/macos/Runner/Configs/Warnings.xcconfig create mode 100644 packages/api/amplify_api/example/macos/Runner/DebugProfile.entitlements create mode 100644 packages/api/amplify_api/example/macos/Runner/Info.plist create mode 100644 packages/api/amplify_api/example/macos/Runner/MainFlutterWindow.swift create mode 100644 packages/api/amplify_api/example/macos/Runner/Release.entitlements create mode 100644 packages/api/amplify_api/example/web/favicon.png create mode 100644 packages/api/amplify_api/example/web/icons/Icon-192.png create mode 100644 packages/api/amplify_api/example/web/icons/Icon-512.png create mode 100644 packages/api/amplify_api/example/web/icons/Icon-maskable-192.png create mode 100644 packages/api/amplify_api/example/web/icons/Icon-maskable-512.png create mode 100644 packages/api/amplify_api/example/web/index.html create mode 100644 packages/api/amplify_api/example/web/manifest.json create mode 100644 packages/api/amplify_api/example/windows/.gitignore create mode 100644 packages/api/amplify_api/example/windows/CMakeLists.txt create mode 100644 packages/api/amplify_api/example/windows/flutter/CMakeLists.txt create mode 100644 packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.cc create mode 100644 packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.h create mode 100644 packages/api/amplify_api/example/windows/flutter/generated_plugins.cmake create mode 100644 packages/api/amplify_api/example/windows/runner/CMakeLists.txt create mode 100644 packages/api/amplify_api/example/windows/runner/Runner.rc create mode 100644 packages/api/amplify_api/example/windows/runner/flutter_window.cpp create mode 100644 packages/api/amplify_api/example/windows/runner/flutter_window.h create mode 100644 packages/api/amplify_api/example/windows/runner/main.cpp create mode 100644 packages/api/amplify_api/example/windows/runner/resource.h create mode 100644 packages/api/amplify_api/example/windows/runner/resources/app_icon.ico create mode 100644 packages/api/amplify_api/example/windows/runner/runner.exe.manifest create mode 100644 packages/api/amplify_api/example/windows/runner/utils.cpp create mode 100644 packages/api/amplify_api/example/windows/runner/utils.h create mode 100644 packages/api/amplify_api/example/windows/runner/win32_window.cpp create mode 100644 packages/api/amplify_api/example/windows/runner/win32_window.h diff --git a/packages/api/amplify_api/example/.metadata b/packages/api/amplify_api/example/.metadata index bcef94eb78..95f73f55c1 100644 --- a/packages/api/amplify_api/example/.metadata +++ b/packages/api/amplify_api/example/.metadata @@ -1,10 +1,45 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled and should not be manually edited. +# This file should be version controlled. version: - revision: bbfbf1770cca2da7c82e887e4e4af910034800b6 + revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 channel: stable project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + - platform: android + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + - platform: ios + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + - platform: linux + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + - platform: macos + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + - platform: web + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + - platform: windows + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/api/amplify_api/example/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/api/amplify_api/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000000..f74085f3f6 --- /dev/null +++ b/packages/api/amplify_api/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/api/amplify_api/example/android/app/src/main/res/values-night/styles.xml b/packages/api/amplify_api/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..06952be745 --- /dev/null +++ b/packages/api/amplify_api/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/api/amplify_api/example/linux/.gitignore b/packages/api/amplify_api/example/linux/.gitignore new file mode 100644 index 0000000000..d3896c9844 --- /dev/null +++ b/packages/api/amplify_api/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/packages/api/amplify_api/example/linux/CMakeLists.txt b/packages/api/amplify_api/example/linux/CMakeLists.txt new file mode 100644 index 0000000000..7070609222 --- /dev/null +++ b/packages/api/amplify_api/example/linux/CMakeLists.txt @@ -0,0 +1,138 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.amazonaws.amplify.example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/packages/api/amplify_api/example/linux/flutter/CMakeLists.txt b/packages/api/amplify_api/example/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000000..d5bd01648a --- /dev/null +++ b/packages/api/amplify_api/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.cc b/packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..e71a16d23d --- /dev/null +++ b/packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.h b/packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..e0f0a47bc0 --- /dev/null +++ b/packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/api/amplify_api/example/linux/flutter/generated_plugins.cmake b/packages/api/amplify_api/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..2e1de87a7e --- /dev/null +++ b/packages/api/amplify_api/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/api/amplify_api/example/linux/main.cc b/packages/api/amplify_api/example/linux/main.cc new file mode 100644 index 0000000000..e7c5c54370 --- /dev/null +++ b/packages/api/amplify_api/example/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/packages/api/amplify_api/example/linux/my_application.cc b/packages/api/amplify_api/example/linux/my_application.cc new file mode 100644 index 0000000000..0ba8f43096 --- /dev/null +++ b/packages/api/amplify_api/example/linux/my_application.cc @@ -0,0 +1,104 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "example"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/packages/api/amplify_api/example/linux/my_application.h b/packages/api/amplify_api/example/linux/my_application.h new file mode 100644 index 0000000000..72271d5e41 --- /dev/null +++ b/packages/api/amplify_api/example/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/packages/api/amplify_api/example/macos/.gitignore b/packages/api/amplify_api/example/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/packages/api/amplify_api/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/packages/api/amplify_api/example/macos/Flutter/Flutter-Debug.xcconfig b/packages/api/amplify_api/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..4b81f9b2d2 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/api/amplify_api/example/macos/Flutter/Flutter-Release.xcconfig b/packages/api/amplify_api/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..5caa9d1579 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/api/amplify_api/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/api/amplify_api/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000000..cccf817a52 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,10 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { +} diff --git a/packages/api/amplify_api/example/macos/Podfile b/packages/api/amplify_api/example/macos/Podfile new file mode 100644 index 0000000000..dade8dfad0 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/api/amplify_api/example/macos/Runner.xcodeproj/project.pbxproj b/packages/api/amplify_api/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..c84862c675 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,572 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 51; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example.app */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1300; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/packages/api/amplify_api/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/api/amplify_api/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/api/amplify_api/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/api/amplify_api/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..fb7259e177 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/api/amplify_api/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/api/amplify_api/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..1d526a16ed --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/api/amplify_api/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/api/amplify_api/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/api/amplify_api/example/macos/Runner/AppDelegate.swift b/packages/api/amplify_api/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..d53ef64377 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..3c4935a7ca84f0976aca34b7f2895d65fb94d1ea GIT binary patch literal 46993 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 literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000000000000000000000000000000000..ed4cc16421680a50164ba74381b4b35ceaa0ccfc GIT binary patch literal 3276 zcmZ`*X*|?x8~)E?#xi3t91%vcMKbnsIy2_j%QE2ziLq8HEtbf{7%?Q-9a%z_Y^9`> zEHh*&vUG%uWkg7pKTS-`$veH@-Vg8ZdG7oAJ@<88AMX3Z{d}TU-4*=KI1-hF6u>DKF2moPt09c{` zfN3rO$X+gJI&oA$AbgKoTL8PiPI1eFOhHBDvW+$&oPl1s$+O5y3$30Jx9nC_?fg%8Om)@;^P;Ee~8ibejUNlSR{FL7-+ zCzU}3UT98m{kYI^@`mgCOJ))+D#erb#$UWt&((j-5*t1id2Zak{`aS^W*K5^gM02# zUAhZn-JAUK>i+SNuFbWWd*7n1^!}>7qZ1CqCl*T+WoAy&z9pm~0AUt1cCV24f z3M@&G~UKrjVHa zjcE@a`2;M>eV&ocly&W3h{`Kt`1Fpp?_h~9!Uj5>0eXw@$opV(@!pixIux}s5pvEqF5$OEMG0;c zAfMxC(-;nx_`}8!F?OqK19MeaswOomKeifCG-!9PiHSU$yamJhcjXiq)-}9`M<&Au|H!nKY(0`^x16f205i2i;E%(4!?0lLq0sH_%)Wzij)B{HZxYWRl3DLaN5`)L zx=x=|^RA?d*TRCwF%`zN6wn_1C4n;lZG(9kT;2Uhl&2jQYtC1TbwQlP^BZHY!MoHm zjQ9)uu_K)ObgvvPb}!SIXFCtN!-%sBQe{6NU=&AtZJS%}eE$i}FIll!r>~b$6gt)V z7x>OFE}YetHPc-tWeu!P@qIWb@Z$bd!*!*udxwO6&gJ)q24$RSU^2Mb%-_`dR2`nW z)}7_4=iR`Tp$TPfd+uieo)8B}Q9#?Szmy!`gcROB@NIehK|?!3`r^1>av?}e<$Qo` zo{Qn#X4ktRy<-+f#c@vILAm;*sfS}r(3rl+{op?Hx|~DU#qsDcQDTvP*!c>h*nXU6 zR=Un;i9D!LcnC(AQ$lTUv^pgv4Z`T@vRP3{&xb^drmjvOruIBJ%3rQAFLl7d9_S64 zN-Uv?R`EzkbYIo)af7_M=X$2p`!u?nr?XqQ_*F-@@(V zFbNeVEzbr;i2fefJ@Gir3-s`syC93he_krL1eb;r(}0yUkuEK34aYvC@(yGi`*oq? zw5g_abg=`5Fdh1Z+clSv*N*Jifmh&3Ghm0A=^s4be*z5N!i^FzLiShgkrkwsHfMjf z*7&-G@W>p6En#dk<^s@G?$7gi_l)y7k`ZY=?ThvvVKL~kM{ehG7-q6=#%Q8F&VsB* zeW^I zUq+tV(~D&Ii_=gn-2QbF3;Fx#%ajjgO05lfF8#kIllzHc=P}a3$S_XsuZI0?0__%O zjiL!@(C0$Nr+r$>bHk(_oc!BUz;)>Xm!s*C!32m1W<*z$^&xRwa+AaAG= z9t4X~7UJht1-z88yEKjJ68HSze5|nKKF9(Chw`{OoG{eG0mo`^93gaJmAP_i_jF8a z({|&fX70PXVE(#wb11j&g4f{_n>)wUYIY#vo>Rit(J=`A-NYYowTnl(N6&9XKIV(G z1aD!>hY!RCd^Sy#GL^0IgYF~)b-lczn+X}+eaa)%FFw41P#f8n2fm9=-4j7}ULi@Z zm=H8~9;)ShkOUAitb!1fvv%;2Q+o)<;_YA1O=??ie>JmIiTy6g+1B-1#A(NAr$JNL znVhfBc8=aoz&yqgrN|{VlpAniZVM?>0%bwB6>}S1n_OURps$}g1t%)YmCA6+5)W#B z=G^KX>C7x|X|$~;K;cc2x8RGO2{{zmjPFrfkr6AVEeW2$J9*~H-4~G&}~b+Pb}JJdODU|$n1<7GPa_>l>;{NmA^y_eXTiv z)T61teOA9Q$_5GEA_ox`1gjz>3lT2b?YY_0UJayin z64qq|Nb7^UhikaEz3M8BKhNDhLIf};)NMeS8(8?3U$ThSMIh0HG;;CW$lAp0db@s0 zu&jbmCCLGE*NktXVfP3NB;MQ>p?;*$-|htv>R`#4>OG<$_n)YvUN7bwzbWEsxAGF~ zn0Vfs?Dn4}Vd|Cf5T-#a52Knf0f*#2D4Lq>-Su4g`$q={+5L$Ta|N8yfZ}rgQm;&b z0A4?$Hg5UkzI)29=>XSzdH4wH8B@_KE{mSc>e3{yGbeiBY_+?^t_a#2^*x_AmN&J$ zf9@<5N15~ty+uwrz0g5k$sL9*mKQazK2h19UW~#H_X83ap-GAGf#8Q5b8n@B8N2HvTiZu&Mg+xhthyG3#0uIny33r?t&kzBuyI$igd`%RIcO8{s$$R3+Z zt{ENUO)pqm_&<(vPf*$q1FvC}W&G)HQOJd%x4PbxogX2a4eW-%KqA5+x#x`g)fN&@ zLjG8|!rCj3y0%N)NkbJVJgDu5tOdMWS|y|Tsb)Z04-oAVZ%Mb311P}}SG#!q_ffMV z@*L#25zW6Ho?-x~8pKw4u9X)qFI7TRC)LlEL6oQ9#!*0k{=p?Vf_^?4YR(M z`uD+8&I-M*`sz5af#gd$8rr|oRMVgeI~soPKB{Q{FwV-FW)>BlS?inI8girWs=mo5b18{#~CJz!miCgQYU>KtCPt()StN;x)c2P3bMVB$o(QUh z$cRQlo_?#k`7A{Tw z!~_YKSd(%1dBM+KE!5I2)ZZsGz|`+*fB*n}yxtKVyx14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>GbI`Jdw*pGcA%L+*Q#&*YQOJ$_%U#(BDn``;rKxi&&)LfRxIZ*98z8UWRslDo@Xu)QVh}rB>bKwe@Bjzwg%m$hd zG)gFMgHZlPxGcm3paLLb44yHI|Ag0wdp!_yD5R<|B29Ui~27`?vfy#ktk_KyHWMDA42{J=Uq-o}i z*%kZ@45mQ-Rw?0?K+z{&5KFc}xc5Q%1PFAbL_xCmpj?JNAm>L6SjrCMpiK}5LG0ZE zO>_%)r1c48n{Iv*t(u1=&kH zeO=ifbFy+6aSK)V_5t;NKhE#$Iz=+Oii|KDJ}W>g}0%`Svgra*tnS6TRU4iTH*e=dj~I` zym|EM*}I1?pT2#3`oZ(|3I-Y$DkeHMN=8~%YSR?;>=X?(Emci*ZIz9+t<|S1>hE8$ zVa1LmTh{DZv}x6@Wz!a}+qZDz%AHHMuHCzM^XlEpr!QPzf9QzkS_0!&1MPx*ICxe}RFdTH+c}l9E`G zYL#4+3Zxi}3=A!G4S>ir#L(2r)WFKnP}jiR%D`ZOPH`@ZhTQy=%(P0}8ZH)|z6jL7 N;OXk;vd$@?2>?>Ex^Vyi literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000000000000000000000000000000000..bcbf36df2f2aaaa0a63c7dabc94e600184229d0d GIT binary patch literal 5933 zcmZ{Idpwix|Np(&m_yAF>K&UIn{t*2ZOdsShYs(MibU!|=pZCJq~7E>B$QJr)hC5| zmk?V?ES039lQ~RC!kjkl-TU4?|NZ{>J$CPLUH9vHy`Hbhhnc~SD_vpzBp6Xw4`$%jfmPw(;etLCccvfU-s)1A zLl8-RiSx!#?Kwzd0E&>h;Fc z^;S84cUH7gMe#2}MHYcDXgbkI+Qh^X4BV~6y<@s`gMSNX!4@g8?ojjj5hZj5X4g9D zavr_NoeZ=4vim%!Y`GnF-?2_Gb)g$xAo>#zCOLB-jPww8a%c|r&DC=eVdE;y+HwH@ zy`JK(oq+Yw^-hLvWO4B8orWwLiKT!hX!?xw`kz%INd5f)>k1PZ`ZfM&&Ngw)HiXA| ze=+%KkiLe1hd>h!ZO2O$45alH0O|E+>G2oCiJ|3y2c$;XedBozx93BprOr$#d{W5sb*hQQ~M@+v_m!8s?9+{Q0adM?ip3qQ*P5$R~dFvP+5KOH_^A+l-qu5flE*KLJp!rtjqTVqJsmpc1 zo>T>*ja-V&ma7)K?CE9RTsKQKk7lhx$L`9d6-Gq`_zKDa6*>csToQ{&0rWf$mD7x~S3{oA z1wUZl&^{qbX>y*T71~3NWd1Wfgjg)<~BnK96Ro#om&~8mU{}D!Fu# zTrKKSM8gY^*47b2Vr|ZZe&m9Y`n+Y8lHvtlBbIjNl3pGxU{!#Crl5RPIO~!L5Y({ym~8%Ox-9g>IW8 zSz2G6D#F|L^lcotrZx4cFdfw6f){tqITj6>HSW&ijlgTJTGbc7Q#=)*Be0-s0$fCk z^YaG;7Q1dfJq#p|EJ~YYmqjs`M0jPl=E`Id{+h%Lo*|8xp6K7yfgjqiH7{61$4x~A zNnH+65?QCtL;_w(|mDNJXybin=rOy-i7A@lXEu z&jY(5jhjlP{TsjMe$*b^2kp8LeAXu~*q&5;|3v|4w4Ij_4c{4GG8={;=K#lh{#C8v z&t9d7bf{@9aUaE94V~4wtQ|LMT*Ruuu0Ndjj*vh2pWW@|KeeXi(vt!YXi~I6?r5PG z$_{M*wrccE6x42nPaJUO#tBu$l#MInrZhej_Tqki{;BT0VZeb$Ba%;>L!##cvieb2 zwn(_+o!zhMk@l~$$}hivyebloEnNQmOy6biopy`GL?=hN&2)hsA0@fj=A^uEv~TFE z<|ZJIWplBEmufYI)<>IXMv(c+I^y6qBthESbAnk?0N(PI>4{ASayV1ErZ&dsM4Z@E-)F&V0>tIF+Oubl zin^4Qx@`Un4kRiPq+LX5{4*+twI#F~PE7g{FpJ`{)K()FH+VG^>)C-VgK>S=PH!m^ zE$+Cfz!Ja`s^Vo(fd&+U{W|K$e(|{YG;^9{D|UdadmUW;j;&V!rU)W_@kqQj*Frp~ z7=kRxk)d1$$38B03-E_|v=<*~p3>)2w*eXo(vk%HCXeT5lf_Z+D}(Uju=(WdZ4xa( zg>98lC^Z_`s-=ra9ZC^lAF?rIvQZpAMz8-#EgX;`lc6*53ckpxG}(pJp~0XBd9?RP zq!J-f`h0dC*nWxKUh~8YqN{SjiJ6vLBkMRo?;|eA(I!akhGm^}JXoL_sHYkGEQWWf zTR_u*Ga~Y!hUuqb`h|`DS-T)yCiF#s<KR}hC~F%m)?xjzj6w#Za%~XsXFS@P0E3t*qs)tR43%!OUxs(|FTR4Sjz(N zppN>{Ip2l3esk9rtB#+To92s~*WGK`G+ECt6D>Bvm|0`>Img`jUr$r@##&!1Ud{r| zgC@cPkNL_na`74%fIk)NaP-0UGq`|9gB}oHRoRU7U>Uqe!U61fY7*Nj(JiFa-B7Av z;VNDv7Xx&CTwh(C2ZT{ot`!E~1i1kK;VtIh?;a1iLWifv8121n6X!{C%kw|h-Z8_U z9Y8M38M2QG^=h+dW*$CJFmuVcrvD*0hbFOD=~wU?C5VqNiIgAs#4axofE*WFYd|K;Et18?xaI|v-0hN#D#7j z5I{XH)+v0)ZYF=-qloGQ>!)q_2S(Lg3<=UsLn%O)V-mhI-nc_cJZu(QWRY)*1il%n zOR5Kdi)zL-5w~lOixilSSF9YQ29*H+Br2*T2lJ?aSLKBwv7}*ZfICEb$t>z&A+O3C z^@_rpf0S7MO<3?73G5{LWrDWfhy-c7%M}E>0!Q(Iu71MYB(|gk$2`jH?!>ND0?xZu z1V|&*VsEG9U zm)!4#oTcgOO6Hqt3^vcHx>n}%pyf|NSNyTZX*f+TODT`F%IyvCpY?BGELP#s<|D{U z9lUTj%P6>^0Y$fvIdSj5*=&VVMy&nms=!=2y<5DP8x;Z13#YXf7}G)sc$_TQQ=4BD zQ1Le^y+BwHl7T6)`Q&9H&A2fJ@IPa;On5n!VNqWUiA*XXOnvoSjEIKW<$V~1?#zts>enlSTQaG2A|Ck4WkZWQoeOu(te znV;souKbA2W=)YWldqW@fV^$6EuB`lFmXYm%WqI}X?I1I7(mQ8U-pm+Ya* z|7o6wac&1>GuQfIvzU7YHIz_|V;J*CMLJolXMx^9CI;I+{Nph?sf2pX@%OKT;N@Uz9Y zzuNq11Ccdwtr(TDLx}N!>?weLLkv~i!xfI0HGWff*!12E*?7QzzZT%TX{5b7{8^*A z3ut^C4uxSDf=~t4wZ%L%gO_WS7SR4Ok7hJ;tvZ9QBfVE%2)6hE>xu9y*2%X5y%g$8 z*8&(XxwN?dO?2b4VSa@On~5A?zZZ{^s3rXm54Cfi-%4hBFSk|zY9u(3d1ButJuZ1@ zfOHtpSt)uJnL`zg9bBvUkjbPO0xNr{^{h0~$I$XQzel_OIEkgT5L!dW1uSnKsEMVp z9t^dfkxq=BneR9`%b#nWSdj)u1G=Ehv0$L@xe_eG$Ac%f7 zy`*X(p0r3FdCTa1AX^BtmPJNR4%S1nyu-AM-8)~t-KII9GEJU)W^ng7C@3%&3lj$2 z4niLa8)fJ2g>%`;;!re+Vh{3V^}9osx@pH8>b0#d8p`Dgm{I?y@dUJ4QcSB<+FAuT)O9gMlwrERIy z6)DFLaEhJkQ7S4^Qr!JA6*SYni$THFtE)0@%!vAw%X7y~!#k0?-|&6VIpFY9>5GhK zr;nM-Z`Omh>1>7;&?VC5JQoKi<`!BU_&GLzR%92V$kMohNpMDB=&NzMB&w-^SF~_# zNsTca>J{Y555+z|IT75yW;wi5A1Z zyzv|4l|xZ-Oy8r8_c8X)h%|a8#(oWcgS5P6gtuCA_vA!t=)IFTL{nnh8iW!B$i=Kd zj1ILrL;ht_4aRKF(l1%^dUyVxgK!2QsL)-{x$`q5wWjjN6B!Cj)jB=bii;9&Ee-;< zJfVk(8EOrbM&5mUciP49{Z43|TLoE#j(nQN_MaKt16dp#T6jF7z?^5*KwoT-Y`rs$ z?}8)#5Dg-Rx!PTa2R5; zx0zhW{BOpx_wKPlTu;4ev-0dUwp;g3qqIi|UMC@A?zEb3RXY`z_}gbwju zzlNht0WR%g@R5CVvg#+fb)o!I*Zpe?{_+oGq*wOmCWQ=(Ra-Q9mx#6SsqWAp*-Jzb zKvuPthpH(Fn_k>2XPu!=+C{vZsF8<9p!T}U+ICbNtO}IAqxa57*L&T>M6I0ogt&l> z^3k#b#S1--$byAaU&sZL$6(6mrf)OqZXpUPbVW%T|4T}20q9SQ&;3?oRz6rSDP4`b z(}J^?+mzbp>MQDD{ziSS0K(2^V4_anz9JV|Y_5{kF3spgW%EO6JpJ(rnnIN%;xkKf zn~;I&OGHKII3ZQ&?sHlEy)jqCyfeusjPMo7sLVr~??NAknqCbuDmo+7tp8vrKykMb z(y`R)pVp}ZgTErmi+z`UyQU*G5stQRsx*J^XW}LHi_af?(bJ8DPho0b)^PT|(`_A$ zFCYCCF={BknK&KYTAVaHE{lqJs4g6B@O&^5oTPLkmqAB#T#m!l9?wz!C}#a6w)Z~Z z6jx{dsXhI(|D)x%Yu49%ioD-~4}+hCA8Q;w_A$79%n+X84jbf?Nh?kRNRzyAi{_oV zU)LqH-yRdPxp;>vBAWqH4E z(WL)}-rb<_R^B~fI%ddj?Qxhp^5_~)6-aB`D~Nd$S`LY_O&&Fme>Id)+iI>%9V-68 z3crl=15^%0qA~}ksw@^dpZ`p;m=ury;-OV63*;zQyRs4?1?8lbUL!bR+C~2Zz1O+E@6ZQW!wvv z|NLqSP0^*J2Twq@yws%~V0^h05B8BMNHv_ZZT+=d%T#i{faiqN+ut5Bc`uQPM zgO+b1uj;)i!N94RJ>5RjTNXN{gAZel|L8S4r!NT{7)_=|`}D~ElU#2er}8~UE$Q>g zZryBhOd|J-U72{1q;Lb!^3mf+H$x6(hJHn$ZJRqCp^In_PD+>6KWnCnCXA35(}g!X z;3YI1luR&*1IvESL~*aF8(?4deU`9!cxB{8IO?PpZ{O5&uY<0DIERh2wEoAP@bayv z#$WTjR*$bN8^~AGZu+85uHo&AulFjmh*pupai?o?+>rZ7@@Xk4muI}ZqH`n&<@_Vn zvT!GF-_Ngd$B7kLge~&3qC;TE=tEid(nQB*qzXI0m46ma*2d(Sd*M%@Zc{kCFcs;1 zky%U)Pyg3wm_g12J`lS4n+Sg=L)-Y`bU705E5wk&zVEZw`eM#~AHHW96@D>bz#7?- zV`xlac^e`Zh_O+B5-kO=$04{<cKUG?R&#bnF}-?4(Jq+?Ph!9g zx@s~F)Uwub>Ratv&v85!6}3{n$bYb+p!w(l8Na6cSyEx#{r7>^YvIj8L?c*{mcB^x zqnv*lu-B1ORFtrmhfe}$I8~h*3!Ys%FNQv!P2tA^wjbH f$KZHO*s&vt|9^w-6P?|#0pRK8NSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fg!ItFh?!xdN1Q+aGJ{c&& zS>O>_%)r1c48n{Iv*t(u1=&kHeO=ifbFy+6aSK)V_AxLppYn8Z42d|rc6w}vOsL55 z`t&mC&y2@JTEyg!eDiFX^k#CC!jq%>erB=yHqUP0XcDOTw6ko}L zX;EmMrq(fKk*eygEuA616;0)>@A{TK|55PV@70 z$OfzS*(VJxQev3J?yY?O=ul(v`fp}?u9z`JK3ugibK>)DyCwImZOF4d{xK%%Ks1*} zv$oa)9anR%lXIBUqYnhLmT>VOzHfNP?ZwJNZ!5$s9M08RynIvaXw>@G^T9@r9^KH1 zVy??F&uuk)bH9Y4pQY!hP58i_H6 znl-NcuCpLV6ZWU;4C zu@9exF&OZi`Bovq_m%T+WhU2kvkz@^_LpycBvqm3bMpLw8X-Or5sL>0AKE1$(k_L=_Zc=CUq#=x1-QZf)G7nHu@fmsQ1eN_N3+nTEz`4HI4Z6uVlE zJH+X&det8JU?tO?upcM4Z=cV!JV;yF>FfL5Q$M|W_2Z!P`S=}Wzp|_1^#d%e?_H`> zV@%vA$+bFVqhw9`U;TfP|5|PD{||OiYdor8P*i??|NJcb%kzT_73*7WE?Ua5hAnR2 z=7WE=PhTlJ#ZeRznjTUb;`E(wkMZrj4e|Hilz-mK>9cZHQY**5TUPw~u}k;u73KI}xAx!0m-)GVia|x^d3p~s_9gh83jA&Ra<8rM%`>U3x69t&NzbwWY}7Ar?)FK#IZ0z|d0H0EkRO w3{9;}4Xg|ebq&m|3=9_N6z8I7$jwj5OsmAL;bP(Gi$Dzwp00i_>zopr02+f8CIA2c literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000000000000000000000000000000000000..e71a726136a47ed24125c7efc79d68a4a01961b4 GIT binary patch literal 14800 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/api/amplify_api/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/api/amplify_api/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..351ec5d179 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.amazonaws.amplify.example + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2022 com.amazonaws.amplify. All rights reserved. diff --git a/packages/api/amplify_api/example/macos/Runner/Configs/Debug.xcconfig b/packages/api/amplify_api/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/api/amplify_api/example/macos/Runner/Configs/Release.xcconfig b/packages/api/amplify_api/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/api/amplify_api/example/macos/Runner/Configs/Warnings.xcconfig b/packages/api/amplify_api/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/packages/api/amplify_api/example/macos/Runner/DebugProfile.entitlements b/packages/api/amplify_api/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..dddb8a30c8 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/packages/api/amplify_api/example/macos/Runner/Info.plist b/packages/api/amplify_api/example/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/packages/api/amplify_api/example/macos/Runner/MainFlutterWindow.swift b/packages/api/amplify_api/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..2722837ec9 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController.init() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/packages/api/amplify_api/example/macos/Runner/Release.entitlements b/packages/api/amplify_api/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..852fa1a472 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/packages/api/amplify_api/example/web/favicon.png b/packages/api/amplify_api/example/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/web/icons/Icon-192.png b/packages/api/amplify_api/example/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/web/icons/Icon-512.png b/packages/api/amplify_api/example/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/web/icons/Icon-maskable-192.png b/packages/api/amplify_api/example/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/web/icons/Icon-maskable-512.png b/packages/api/amplify_api/example/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/web/index.html b/packages/api/amplify_api/example/web/index.html new file mode 100644 index 0000000000..41b3bc336f --- /dev/null +++ b/packages/api/amplify_api/example/web/index.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + example + + + + + + + + + + diff --git a/packages/api/amplify_api/example/web/manifest.json b/packages/api/amplify_api/example/web/manifest.json new file mode 100644 index 0000000000..096edf8fe4 --- /dev/null +++ b/packages/api/amplify_api/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "example", + "short_name": "example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/packages/api/amplify_api/example/windows/.gitignore b/packages/api/amplify_api/example/windows/.gitignore new file mode 100644 index 0000000000..d492d0d98c --- /dev/null +++ b/packages/api/amplify_api/example/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/packages/api/amplify_api/example/windows/CMakeLists.txt b/packages/api/amplify_api/example/windows/CMakeLists.txt new file mode 100644 index 0000000000..c0270746b1 --- /dev/null +++ b/packages/api/amplify_api/example/windows/CMakeLists.txt @@ -0,0 +1,101 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(example LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/packages/api/amplify_api/example/windows/flutter/CMakeLists.txt b/packages/api/amplify_api/example/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000000..930d2071a3 --- /dev/null +++ b/packages/api/amplify_api/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,104 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + windows-x64 $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.cc b/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..8b6d4680af --- /dev/null +++ b/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.h b/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..dc139d85a9 --- /dev/null +++ b/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/api/amplify_api/example/windows/flutter/generated_plugins.cmake b/packages/api/amplify_api/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..b93c4c30c1 --- /dev/null +++ b/packages/api/amplify_api/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/api/amplify_api/example/windows/runner/CMakeLists.txt b/packages/api/amplify_api/example/windows/runner/CMakeLists.txt new file mode 100644 index 0000000000..b9e550fba8 --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/packages/api/amplify_api/example/windows/runner/Runner.rc b/packages/api/amplify_api/example/windows/runner/Runner.rc new file mode 100644 index 0000000000..49373857ac --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#ifdef FLUTTER_BUILD_NUMBER +#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#else +#define VERSION_AS_NUMBER 1,0,0 +#endif + +#ifdef FLUTTER_BUILD_NAME +#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.amazonaws.amplify" "\0" + VALUE "FileDescription", "example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2022 com.amazonaws.amplify. All rights reserved." "\0" + VALUE "OriginalFilename", "example.exe" "\0" + VALUE "ProductName", "example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/packages/api/amplify_api/example/windows/runner/flutter_window.cpp b/packages/api/amplify_api/example/windows/runner/flutter_window.cpp new file mode 100644 index 0000000000..b43b9095ea --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/flutter_window.cpp @@ -0,0 +1,61 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/packages/api/amplify_api/example/windows/runner/flutter_window.h b/packages/api/amplify_api/example/windows/runner/flutter_window.h new file mode 100644 index 0000000000..6da0652f05 --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/packages/api/amplify_api/example/windows/runner/main.cpp b/packages/api/amplify_api/example/windows/runner/main.cpp new file mode 100644 index 0000000000..bcb57b0e2a --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.CreateAndShow(L"example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/packages/api/amplify_api/example/windows/runner/resource.h b/packages/api/amplify_api/example/windows/runner/resource.h new file mode 100644 index 0000000000..66a65d1e4a --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/packages/api/amplify_api/example/windows/runner/resources/app_icon.ico b/packages/api/amplify_api/example/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c04e20caf6370ebb9253ad831cc31de4a9c965f6 GIT binary patch literal 33772 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 literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/windows/runner/runner.exe.manifest b/packages/api/amplify_api/example/windows/runner/runner.exe.manifest new file mode 100644 index 0000000000..c977c4a425 --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/packages/api/amplify_api/example/windows/runner/utils.cpp b/packages/api/amplify_api/example/windows/runner/utils.cpp new file mode 100644 index 0000000000..f5bf9fa0f5 --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/utils.cpp @@ -0,0 +1,64 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, utf8_string.data(), + target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/packages/api/amplify_api/example/windows/runner/utils.h b/packages/api/amplify_api/example/windows/runner/utils.h new file mode 100644 index 0000000000..3879d54755 --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/packages/api/amplify_api/example/windows/runner/win32_window.cpp b/packages/api/amplify_api/example/windows/runner/win32_window.cpp new file mode 100644 index 0000000000..c10f08dc7d --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/win32_window.cpp @@ -0,0 +1,245 @@ +#include "win32_window.h" + +#include + +#include "resource.h" + +namespace { + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + FreeLibrary(user32_module); + } +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + return OnCreate(); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} diff --git a/packages/api/amplify_api/example/windows/runner/win32_window.h b/packages/api/amplify_api/example/windows/runner/win32_window.h new file mode 100644 index 0000000000..17ba431125 --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/win32_window.h @@ -0,0 +1,98 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates and shows a win32 window with |title| and position and size using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size to will treat the width height passed in to this function + // as logical pixels and scale to appropriate for the default monitor. Returns + // true if the window was created successfully. + bool CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responsponds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ From bef7f192bda9e3a8a6c4905eba17cee7094422c7 Mon Sep 17 00:00:00 2001 From: equartey Date: Tue, 30 Aug 2022 11:13:24 -0700 Subject: [PATCH 13/47] feat(api): Subscription Reconnection --- .../Flutter/GeneratedPluginRegistrant.swift | 2 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + .../amplify_core/lib/src/hub/hub_channel.dart | 5 +- .../lib/src/types/api/api_types.dart | 4 + .../graphql/graphql_subscription_options.dart | 31 +++ .../lib/src/types/api/hub/api_hub_event.dart | 27 +++ .../api/hub/api_subscription_hub_event.dart | 179 +++++++++++++++++ packages/amplify_core/pubspec.yaml | 1 + .../example/lib/graphql_api_view.dart | 2 + .../api/amplify_api/example/lib/main.dart | 22 ++- packages/api/amplify_api/example/pubspec.yaml | 1 + packages/api/amplify_api/lib/amplify_api.dart | 13 +- .../amplify_api/lib/src/api_plugin_impl.dart | 8 +- .../src/graphql/ws/web_socket_connection.dart | 183 +++++++++++++++++- ...web_socket_message_stream_transformer.dart | 6 +- packages/api/amplify_api/pubspec.yaml | 2 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 23 files changed, 488 insertions(+), 14 deletions(-) create mode 100644 packages/amplify_core/lib/src/types/api/graphql/graphql_subscription_options.dart create mode 100644 packages/amplify_core/lib/src/types/api/hub/api_hub_event.dart create mode 100644 packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart diff --git a/packages/amplify/amplify_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/amplify/amplify_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817a52..be95aee804 100644 --- a/packages/amplify/amplify_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/amplify/amplify_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import connectivity_plus_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) } diff --git a/packages/amplify/amplify_flutter/example/windows/flutter/generated_plugin_registrant.cc b/packages/amplify/amplify_flutter/example/windows/flutter/generated_plugin_registrant.cc index 8b6d4680af..8083d749b1 100644 --- a/packages/amplify/amplify_flutter/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/amplify/amplify_flutter/example/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); } diff --git a/packages/amplify/amplify_flutter/example/windows/flutter/generated_plugins.cmake b/packages/amplify/amplify_flutter/example/windows/flutter/generated_plugins.cmake index b93c4c30c1..8cf5d42678 100644 --- a/packages/amplify/amplify_flutter/example/windows/flutter/generated_plugins.cmake +++ b/packages/amplify/amplify_flutter/example/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/packages/amplify_authenticator/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/amplify_authenticator/example/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817a52..be95aee804 100644 --- a/packages/amplify_authenticator/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/amplify_authenticator/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import connectivity_plus_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) } diff --git a/packages/amplify_authenticator/example/windows/flutter/generated_plugin_registrant.cc b/packages/amplify_authenticator/example/windows/flutter/generated_plugin_registrant.cc index 8b6d4680af..8083d749b1 100644 --- a/packages/amplify_authenticator/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/amplify_authenticator/example/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); } diff --git a/packages/amplify_authenticator/example/windows/flutter/generated_plugins.cmake b/packages/amplify_authenticator/example/windows/flutter/generated_plugins.cmake index b93c4c30c1..8cf5d42678 100644 --- a/packages/amplify_authenticator/example/windows/flutter/generated_plugins.cmake +++ b/packages/amplify_authenticator/example/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/packages/amplify_core/lib/src/hub/hub_channel.dart b/packages/amplify_core/lib/src/hub/hub_channel.dart index 662dc9a4ca..ba32108b44 100644 --- a/packages/amplify_core/lib/src/hub/hub_channel.dart +++ b/packages/amplify_core/lib/src/hub/hub_channel.dart @@ -1,5 +1,5 @@ /* - * Copyright 2021 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. @@ -28,4 +28,7 @@ enum HubChannel> { /// Events of the DataStore category. DataStore(), + + /// Events of the API category + Api(); } 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 299fd03412..d3d26e8feb 100644 --- a/packages/amplify_core/lib/src/types/api/api_types.dart +++ b/packages/amplify_core/lib/src/types/api/api_types.dart @@ -26,6 +26,10 @@ export 'graphql/graphql_request_type.dart'; export 'graphql/graphql_response.dart'; export 'graphql/graphql_response_error.dart'; export 'graphql/graphql_subscription_operation.dart'; +export 'graphql/graphql_subscription_options.dart'; + +export 'hub/api_hub_event.dart'; +export 'hub/api_subscription_hub_event.dart'; export 'rest/http_payload.dart'; export 'rest/rest_exception.dart'; diff --git a/packages/amplify_core/lib/src/types/api/graphql/graphql_subscription_options.dart b/packages/amplify_core/lib/src/types/api/graphql/graphql_subscription_options.dart new file mode 100644 index 0000000000..afb07af288 --- /dev/null +++ b/packages/amplify_core/lib/src/types/api/graphql/graphql_subscription_options.dart @@ -0,0 +1,31 @@ +/* + * 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:retry/retry.dart'; + +/// Confiuration options for GraphQL Subscriptions and their websockets. +class GraphQLSubscriptionOptions { + /// Configure the ping interval for AppSync polling for subscription connections. + final Duration? pingInterval; + + /// Configure the timeout period when trying to reconnect a web socket. + final Duration? retryTimeout; + + /// Configure the exponential retry strategy options + final RetryOptions? retryOptions; + + const GraphQLSubscriptionOptions( + {this.pingInterval, this.retryTimeout, this.retryOptions}); +} diff --git a/packages/amplify_core/lib/src/types/api/hub/api_hub_event.dart b/packages/amplify_core/lib/src/types/api/hub/api_hub_event.dart new file mode 100644 index 0000000000..d159dcd34b --- /dev/null +++ b/packages/amplify_core/lib/src/types/api/hub/api_hub_event.dart @@ -0,0 +1,27 @@ +/* + * 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'; + +class ApiHubEvent extends HubEvent { + ApiHubEvent( + super.eventName, { + super.payload, + }); +} + +abstract class ApiHubEventPayload { + const ApiHubEventPayload(); +} diff --git a/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart b/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart new file mode 100644 index 0000000000..b636aa720b --- /dev/null +++ b/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart @@ -0,0 +1,179 @@ +/* + * 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'; + +/// {@template amplify_common.hub.api_network_state} +/// Network states for graphql subscriptions. +/// {@endtemplate} +enum NetworkState { + /// {@macro amplify_common.hub.api_network_state_connected} + connected('CONNECTED'), + + /// {@macro amplify_common.hub.api_network_state_disconnected} + disconnected('DISCONNECTED'), + + /// {@macro amplify_common.hub.api_network_state_failed} + failed('FAILED'); + + /// The event name. + final String state; + + /// {@macro amplify_common.hub.api_network_state} + const NetworkState(this.state); +} + +/// {@template amplify_common.hub.api_intended_state} +/// The intended network states for graphql subscriptions. +/// {@endtemplate} +enum IntendedState { + /// {@macro amplify_common.hub.api_intended_state_connected} + connected('CONNECTED'), + + /// {@macro amplify_common.hub.api_intended_state_disconnected} + disconnected('DISCONNECTED'); + + /// The event name. + final String state; + + /// {@macro amplify_common.hub.api_intended_state} + const IntendedState(this.state); +} + +/// {@template amplify_common.hub.api_subscription_status} +/// A consolidated subscription connection status determined by the network and +/// intended states. +/// {@endtemplate} +enum SubscriptionStatus { + /// {@macro amplify_common.hub.api_subscription_status_connected} + connected('CONNECTED'), + + /// {@macro amplify_common.hub.api_subscription_status_disconnected} + disconnected('DISCONNECTED'), + + /// {@macro amplify_common.hub.api_subscription_status_connected} + connecting('CONNECTING'), + + /// {@macro amplify_common.hub.api_subscription_status_disconnected} + pendingDisconnected('PENDING_DISCONNECTED'), + + /// {@macro amplify_common.hub.api_subscription_status_failed} + failed('FAILED'); + + /// The event name. + final String status; + + /// {@macro amplify_common.hub.api_subscription_status} + const SubscriptionStatus(this.status); +} + +class SubscriptionDetails { + /// {@macro amplify_common.hub.api_network_state} + final NetworkState networkState; + + /// {@macro amplify_common.hub.api_intended_state} + final IntendedState intendedState; + + SubscriptionDetails(this.networkState, this.intendedState); +} + +class SubscriptionHubEvent extends ApiHubEvent + with AWSEquatable { + final SubscriptionDetails details; + + static const String _name = 'SubscriptionHubEvent'; + + SubscriptionHubEvent._(this.details) : super(_name); + + /// {@template amplify_common.hub.api_subscription_status} + /// An overall status for GraphQL subscription connection, determined by the + /// underlying details [networkState] & [intendedState] + /// {@endtemplate} + SubscriptionStatus get status { + // Connection failed + if (details.networkState == NetworkState.failed) { + return SubscriptionStatus.failed; + } + // Connected with active subscriptions + if (details.networkState == NetworkState.connected && + details.intendedState == IntendedState.connected) { + return SubscriptionStatus.connected; + } + // Disconnected with active subscriptions + if (details.networkState == NetworkState.disconnected && + details.intendedState == IntendedState.connected) { + return SubscriptionStatus.connecting; + } + // Connected without active subscriptions + if (details.networkState == NetworkState.connected && + details.intendedState == IntendedState.disconnected) { + return SubscriptionStatus.pendingDisconnected; + } + + // disconnected without active subscriptions + return SubscriptionStatus.disconnected; + } + + /// {@template amplify_common.hub.api_subscrption_connected} + /// Emitted when a GraphQL subscrption is connected. + /// {@endtemplate} + SubscriptionHubEvent.connected() + : this._(SubscriptionDetails( + NetworkState.connected, IntendedState.connected)); + + /// {@template amplify_common.hub.api_subscrption_connected} + /// Emitted when a GraphQL subscrption is connecting/reconnecting. + /// {@endtemplate} + SubscriptionHubEvent.connecting() + : this._(SubscriptionDetails( + NetworkState.disconnected, IntendedState.connected)); + + /// {@template amplify_common.hub.api_subscrption_disconnected} + /// Emitted when a GraphQL subscrption connection has disconnected. + /// {@endtemplate} + SubscriptionHubEvent.disconnected() + : this._(SubscriptionDetails( + NetworkState.disconnected, IntendedState.disconnected)); + + /// {@template amplify_common.hub.api_subscrption_pending_disconnect} + /// Emitted when a GraphQL subscrption connection is pending disconnect, + /// but should exist. + /// {@endtemplate} + SubscriptionHubEvent.pendingDisconnect() + : this._(SubscriptionDetails( + NetworkState.connected, IntendedState.disconnected)); + + /// {@template amplify_common.hub.api_subscrption_failed} + /// Emitted when a GraphQL subscrption connection has failed. + /// {@endtemplate} + SubscriptionHubEvent.failed() + : this._(SubscriptionDetails( + NetworkState.failed, IntendedState.disconnected)); + + @override + List get props => [details, payload]; + + @override + String toString() { + return ''' +SubscriptionHubEvent: { + details: { + networkState: ${details.networkState}, + intendedState: ${details.intendedState}, + } + status: $status, +}'''; + } +} diff --git a/packages/amplify_core/pubspec.yaml b/packages/amplify_core/pubspec.yaml index f044a78d7e..8f19f913ee 100644 --- a/packages/amplify_core/pubspec.yaml +++ b/packages/amplify_core/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: json_annotation: ^4.6.0 logging: ^1.0.0 meta: ^1.7.0 + retry: ^3.1.0 uuid: 3.0.6 dev_dependencies: 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 fa0f2f345f..0b452649e8 100644 --- a/packages/api/amplify_api/example/lib/graphql_api_view.dart +++ b/packages/api/amplify_api/example/lib/graphql_api_view.dart @@ -13,6 +13,8 @@ * permissions and limitations under the License. */ +import 'dart:async'; + import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:async/async.dart'; import 'package:flutter/material.dart'; diff --git a/packages/api/amplify_api/example/lib/main.dart b/packages/api/amplify_api/example/lib/main.dart index 6e5dbf862d..6d6904e07b 100644 --- a/packages/api/amplify_api/example/lib/main.dart +++ b/packages/api/amplify_api/example/lib/main.dart @@ -19,6 +19,7 @@ import 'package:amplify_api_example/rest_api_view.dart'; import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:flutter/material.dart'; +import 'package:retry/retry.dart'; import 'amplifyconfiguration.dart'; @@ -44,7 +45,17 @@ class _MyAppState extends State { } void _configureAmplify() async { - await Amplify.addPlugins([AmplifyAuthCognito(), AmplifyAPI()]); + const retryStrat = RetryOptions(maxAttempts: 0); + + const subscriptionOptions = GraphQLSubscriptionOptions( + pingInterval: Duration(seconds: 30), retryOptions: retryStrat); + + await Amplify.addPlugins([ + AmplifyAuthCognito(), + AmplifyAPI( + subscriptionOptions: subscriptionOptions, + ), + ]); try { await Amplify.configure(amplifyconfig); @@ -55,6 +66,15 @@ class _MyAppState extends State { setState(() { _isAmplifyConfigured = true; }); + + Amplify.Hub.listen( + HubChannel.Api, + ((ApiHubEvent event) { + if (event is SubscriptionHubEvent) { + print(event.toString()); + } + }), + ); } void _onRestApiViewButtonClick() { diff --git a/packages/api/amplify_api/example/pubspec.yaml b/packages/api/amplify_api/example/pubspec.yaml index 3303c4f462..e866652b86 100644 --- a/packages/api/amplify_api/example/pubspec.yaml +++ b/packages/api/amplify_api/example/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: flutter: sdk: flutter + retry: ^3.1.0 dev_dependencies: amplify_lints: diff --git a/packages/api/amplify_api/lib/amplify_api.dart b/packages/api/amplify_api/lib/amplify_api.dart index a4db7b1e97..aa1b068e86 100644 --- a/packages/api/amplify_api/lib/amplify_api.dart +++ b/packages/api/amplify_api/lib/amplify_api.dart @@ -30,11 +30,16 @@ export './model_subscriptions.dart'; /// {@endtemplate} abstract class AmplifyAPI extends APIPluginInterface { /// {@macro amplify_api.amplify_api} - factory AmplifyAPI( - {List authProviders = const [], - ModelProviderInterface? modelProvider}) => + factory AmplifyAPI({ + List authProviders = const [], + ModelProviderInterface? modelProvider, + GraphQLSubscriptionOptions? subscriptionOptions, + }) => AmplifyAPIDart( - authProviders: authProviders, modelProvider: modelProvider); + authProviders: authProviders, + modelProvider: modelProvider, + subscriptionOptions: subscriptionOptions, + ); /// 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 index 66e0d0ca91..0209534b69 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -51,11 +51,15 @@ class AmplifyAPIDart extends AmplifyAPI { /// The registered [APIAuthProvider] instances. final Map _authProviders = {}; + /// Subscription options + final GraphQLSubscriptionOptions? subscriptionOptions; + /// {@macro amplify_api.amplify_api_dart} AmplifyAPIDart({ List authProviders = const [], http.Client? baseHttpClient, this.modelProvider, + this.subscriptionOptions, }) : _baseHttpClient = baseHttpClient, super.protected() { authProviders.forEach(registerAuthProvider); @@ -102,7 +106,8 @@ class AmplifyAPIDart extends AmplifyAPI { _authProviders.keys.map((key) => key.rawValue).toList(); await nativeBridge.addPlugin(authProvidersList); } on PlatformException catch (e) { - if (e.code == 'AmplifyAlreadyConfiguredException') { + if (e.code == 'AmplifyAlreadyConfiguredException' || + e.code == 'AlreadyConfiguredException') { throw const AmplifyAlreadyConfiguredException( AmplifyExceptionMessages.alreadyConfiguredDefaultMessage, recoverySuggestion: @@ -141,6 +146,7 @@ class AmplifyAPIDart extends AmplifyAPI { return _webSocketConnectionPool[endpoint.name] ??= WebSocketConnection( endpoint.config, _authProviderRepo, + subscriptionOptions, logger: _logger.createChild( 'webSocketConnection${endpoint.name}', ), 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 939aab96b8..415501d581 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 @@ -13,12 +13,17 @@ // limitations under the License. import 'dart:async'; +import 'dart:collection'; import 'dart:convert'; 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:connectivity_plus/connectivity_plus.dart'; +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; import 'package:meta/meta.dart'; +import 'package:retry/retry.dart'; import 'package:web_socket_channel/status.dart' as status; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -47,8 +52,21 @@ class WebSocketConnection implements Closeable { // Other events (for single subscriptions) rebroadcast to _rebroadcastController. WebSocketChannel? _channel; StreamSubscription? _subscription; + StreamSubscription? _network; + StreamController? _hubEventsController; RestartableTimer? _timeoutTimer; + Timer? _pingTimer; + bool _hasNetwork = false; + bool _hasConnectionBroken = false; + final Set> _subscriptionRequests = HashSet( + hashCode: ((p0) => p0.id.hashCode), equals: (x, y) => x.id == y.id); + + /// Subscription options + final GraphQLSubscriptionOptions? _subscriptionOptions; + final Duration _defaultPingInterval = const Duration(seconds: 30); + final Duration _defaultRetryTimeout = const Duration(seconds: 5); + // Re-broadcasts incoming messages for child streams (single GraphQL subscriptions). // start_ack, data, error final StreamController _rebroadcastController = @@ -64,7 +82,8 @@ class WebSocketConnection implements Closeable { Future get ready => _connectionReady.future; /// {@macro amplify_api.ws.web_socket_connection} - WebSocketConnection(this._config, this._authProviderRepo, + WebSocketConnection( + this._config, this._authProviderRepo, this._subscriptionOptions, {required AmplifyLogger logger}) : _logger = logger; @@ -74,15 +93,54 @@ class WebSocketConnection implements Closeable { Stream stream) { return stream.transform(const WebSocketMessageStreamTransformer()).listen( _onData, - cancelOnError: true, + onDone: () { + print('stream says its done!'); + _initMemo = AsyncMemoizer(); + }, onError: (Object e) { + if (!_connectionReady.isCompleted) _connectionReady.completeError(e); _connectionError( ApiException('Connection failed.', underlyingException: e.toString()), ); + _hubEventsController?.add(SubscriptionHubEvent.failed()); + + if (_hasNetwork && + _hasConnectionBroken && + _subscriptionRequests.isNotEmpty) { + _retry(); + } }, + // Todo(equartey): investigate why this breaks onDone & onError when an error occurs. + // cancelOnError: true, ); } + /// Connects _network stream for connection status. + @visibleForTesting + StreamSubscription getStreamNetwork() { + return Connectivity() + .onConnectivityChanged + .listen((ConnectivityResult connectivityResult) async { + print('Connection: $connectivityResult'); + switch (connectivityResult) { + case ConnectivityResult.ethernet: + case ConnectivityResult.mobile: + case ConnectivityResult.wifi: + _hasNetwork = true; + // only reconnect if connection was lost + if (_hasConnectionBroken) _retry(); + break; + case ConnectivityResult.none: + _hasNetwork = false; + _hasConnectionBroken = true; + _hubEventsController?.add(SubscriptionHubEvent.connecting()); + break; + default: + break; + } + }); + } + /// Connects WebSocket channel to _subscription stream but does not send connection /// init message. @visibleForTesting @@ -92,6 +150,7 @@ class WebSocketConnection implements Closeable { protocols: webSocketProtocols, ); _subscription = getStreamSubscription(_channel!.stream); + _network = getStreamNetwork(); } void _connectionError(ApiException exception) { @@ -110,6 +169,7 @@ class WebSocketConnection implements Closeable { @override void close([int closeStatus = _defaultCloseStatus]) { _logger.verbose('Closing web socket connection.'); + _hubEventsController?.add(SubscriptionHubEvent.disconnected()); final reason = closeStatus == _defaultCloseStatus ? 'client closed' : 'unknown'; _subscription?.cancel(); @@ -118,6 +178,81 @@ class WebSocketConnection implements Closeable { _rebroadcastController.close(); _timeoutTimer?.cancel(); _resetConnectionInit(); + _subscriptionRequests.clear(); + _network?.cancel(); + _hubEventsController?.close(); + } + + /// Initiate polling + void _startPolling() { + final interval = _subscriptionOptions?.pingInterval ?? _defaultPingInterval; + + _pingTimer?.cancel(); + _pingTimer = Timer.periodic(interval, (_) => _poll()); + _poll(); + } + + /// Pings AppSync to verify health of connection. Failed pings trigger + /// reconnection through retry/back off. + Future _poll() async { + try { + final res = await _getPollRequest(); + if (res.body != 'healthy') { + throw Exception([ + 'Subscription connection broken.', + 'AppSync status check returned: ', + res.body + ]); + } + } on Exception catch (e) { + _pingTimer?.cancel(); + _timeoutTimer?.cancel(); + + // flag for reconnection when network returns + _hasConnectionBroken = true; + + // only try to recover if network is turned on + if (_hasNetwork) { + _retry(); + } else { + _hubEventsController?.add(SubscriptionHubEvent.connecting()); + } + } + } + + /// Attempts to connect to the configured graphql endpoint. + /// Upon successful ping, a new connection is initialized. + Future _retry() async { + final retryTimeout = + _subscriptionOptions?.retryTimeout ?? _defaultRetryTimeout; + + RetryOptions retryStrat = + _subscriptionOptions?.retryOptions ?? const RetryOptions(); + + try { + _hasConnectionBroken = false; + _hubEventsController?.add(SubscriptionHubEvent.connecting()); + + await retryStrat.retry( + // Make a GET request + () => _getPollRequest().timeout(retryTimeout)); + + // we can reach AppSync, proceede with reconnect + _init(); + } on Exception catch (e) { + // no network, can't reconnect + _hubEventsController?.add(SubscriptionHubEvent.disconnected()); + close(); + throw Exception([ + 'Unable to recover network connection.', + 'Web socket will closed.', + ]); + } + } + + Future _getPollRequest() { + return http.Client() + .get(Uri.parse(_config.endpoint.replaceFirst('graphql', 'ping'))); } /// Initializes the connection. @@ -128,13 +263,40 @@ class WebSocketConnection implements Closeable { Future init() => _initMemo.runOnce(_init); Future _init() async { + // Prep new connection with a clean slate + _connectionReady = Completer(); + _timeoutTimer?.cancel(); + _subscription?.cancel(); + _network?.cancel(); + _channel?.sink.close(status.normalClosure); + + // establish hub events controller + if (_hubEventsController == null || _hubEventsController!.isClosed) { + _hubEventsController = StreamController.broadcast(); + Amplify.Hub.addChannel(HubChannel.Api, _hubEventsController!.stream); + } + _hubEventsController?.add(SubscriptionHubEvent.connecting()); + final connectionUri = await generateConnectionUri(_config, _authProviderRepo); await connect(connectionUri); send(WebSocketConnectionInitMessage()); - return ready; + await ready; + + // connection ready, start polling + _startPolling(); + + // resubscribe to previous subscriptions + for (var req in _subscriptionRequests) { + final subscriptionRegistrationMessage = + await generateSubscriptionRegistrationMessage(_config, + id: req.id, authRepo: _authProviderRepo, request: req); + send(subscriptionRegistrationMessage); + } + + _hubEventsController?.add(SubscriptionHubEvent.connected()); } Future _sendSubscriptionRegistrationMessage( @@ -148,6 +310,7 @@ class WebSocketConnection implements Closeable { request: request, ); send(subscriptionRegistrationMessage); + _subscriptionRequests.add(request); } /// Subscribes to the given GraphQL request. Returns the subscription object, @@ -181,8 +344,10 @@ class WebSocketConnection implements Closeable { cancelOnError: true, ); - _sendSubscriptionRegistrationMessage(request) - .catchError(controller.addError); + _sendSubscriptionRegistrationMessage(request).catchError((Object e) { + controller.addError(e); + _hubEventsController?.add(SubscriptionHubEvent.failed()); + }); return controller.stream; } @@ -191,7 +356,10 @@ class WebSocketConnection implements Closeable { 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. + final request = + _subscriptionRequests.firstWhere((r) => r.id == subscriptionId); + _subscriptionRequests.remove(request); + if (_subscriptionRequests.isEmpty) close(status.normalClosure); } /// Serializes a message as JSON string and sends over WebSocket channel. @@ -203,12 +371,15 @@ class WebSocketConnection implements Closeable { /// Times out the connection (usually if a keep alive has not been received in time). void _timeout(Duration timeoutDuration) { + _timeoutTimer?.cancel(); + _hubEventsController?.add(SubscriptionHubEvent.disconnected()); _rebroadcastController.addError( TimeoutException( 'Connection timeout', timeoutDuration, ), ); + close(); } /// Handles incoming data on the WebSocket. 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 e037bd1ba5..3163d52a04 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 @@ -52,6 +52,7 @@ class WebSocketSubscriptionStreamTransformer /// executes when start_ack message received final void Function()? onEstablished; + final Map _establishedRequests = {}; /// [request] is used to properly decode response events /// [onEstablished] is executed when start_ack message received @@ -67,7 +68,10 @@ class WebSocketSubscriptionStreamTransformer await for (var event in stream) { switch (event.messageType) { case MessageType.startAck: - onEstablished?.call(); + if (_establishedRequests[request.id] != true) { + onEstablished?.call(); + _establishedRequests[request.id] = true; + } break; case MessageType.data: final payload = event.payload as SubscriptionDataPayload; diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index aa5240a437..f59755b9d7 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: async: ^2.8.2 aws_common: ^0.2.0 collection: ^1.15.0 + connectivity_plus: ^2.3.6 flutter: sdk: flutter http: ^0.13.4 @@ -25,6 +26,7 @@ dependencies: meta: ^1.7.0 plugin_platform_interface: ^2.0.0 web_socket_channel: ^2.2.0 + retry: ^3.1.0 dev_dependencies: diff --git a/packages/auth/amplify_auth_cognito/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/auth/amplify_auth_cognito/example/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817a52..be95aee804 100644 --- a/packages/auth/amplify_auth_cognito/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/auth/amplify_auth_cognito/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import connectivity_plus_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) } diff --git a/packages/auth/amplify_auth_cognito/example/windows/flutter/generated_plugin_registrant.cc b/packages/auth/amplify_auth_cognito/example/windows/flutter/generated_plugin_registrant.cc index 8b6d4680af..8083d749b1 100644 --- a/packages/auth/amplify_auth_cognito/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/auth/amplify_auth_cognito/example/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); } diff --git a/packages/auth/amplify_auth_cognito/example/windows/flutter/generated_plugins.cmake b/packages/auth/amplify_auth_cognito/example/windows/flutter/generated_plugins.cmake index b93c4c30c1..8cf5d42678 100644 --- a/packages/auth/amplify_auth_cognito/example/windows/flutter/generated_plugins.cmake +++ b/packages/auth/amplify_auth_cognito/example/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From fcc53a06fd3af2ec83bb1daf4f216161c78e5bc8 Mon Sep 17 00:00:00 2001 From: equartey Date: Wed, 31 Aug 2022 10:57:15 -0700 Subject: [PATCH 14/47] fix: unexposed retryTimeout --- .../src/types/api/graphql/graphql_subscription_options.dart | 6 +----- .../lib/src/graphql/ws/web_socket_connection.dart | 5 +---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/amplify_core/lib/src/types/api/graphql/graphql_subscription_options.dart b/packages/amplify_core/lib/src/types/api/graphql/graphql_subscription_options.dart index afb07af288..a7b3840dce 100644 --- a/packages/amplify_core/lib/src/types/api/graphql/graphql_subscription_options.dart +++ b/packages/amplify_core/lib/src/types/api/graphql/graphql_subscription_options.dart @@ -20,12 +20,8 @@ class GraphQLSubscriptionOptions { /// Configure the ping interval for AppSync polling for subscription connections. final Duration? pingInterval; - /// Configure the timeout period when trying to reconnect a web socket. - final Duration? retryTimeout; - /// Configure the exponential retry strategy options final RetryOptions? retryOptions; - const GraphQLSubscriptionOptions( - {this.pingInterval, this.retryTimeout, this.retryOptions}); + const GraphQLSubscriptionOptions({this.pingInterval, this.retryOptions}); } 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 415501d581..7adf64c236 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 @@ -223,9 +223,6 @@ class WebSocketConnection implements Closeable { /// Attempts to connect to the configured graphql endpoint. /// Upon successful ping, a new connection is initialized. Future _retry() async { - final retryTimeout = - _subscriptionOptions?.retryTimeout ?? _defaultRetryTimeout; - RetryOptions retryStrat = _subscriptionOptions?.retryOptions ?? const RetryOptions(); @@ -235,7 +232,7 @@ class WebSocketConnection implements Closeable { await retryStrat.retry( // Make a GET request - () => _getPollRequest().timeout(retryTimeout)); + () => _getPollRequest().timeout(_defaultRetryTimeout)); // we can reach AppSync, proceede with reconnect _init(); From 32178ca950138389fb2374ccb93a22344e8c3257 Mon Sep 17 00:00:00 2001 From: equartey Date: Wed, 31 Aug 2022 11:43:50 -0700 Subject: [PATCH 15/47] fix: more robust uri replacement --- .../lib/src/graphql/ws/web_socket_connection.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 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 7adf64c236..c7f5381fa5 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 @@ -221,7 +221,7 @@ class WebSocketConnection implements Closeable { } /// Attempts to connect to the configured graphql endpoint. - /// Upon successful ping, a new connection is initialized. + /// Upon successful ping, a new websocket connection is initialized. Future _retry() async { RetryOptions retryStrat = _subscriptionOptions?.retryOptions ?? const RetryOptions(); @@ -247,9 +247,10 @@ class WebSocketConnection implements Closeable { } } + /// [GET] request on the configured AppSync url via the `/ping` endpoint Future _getPollRequest() { - return http.Client() - .get(Uri.parse(_config.endpoint.replaceFirst('graphql', 'ping'))); + final pingUri = Uri.parse(_config.endpoint).replace(path: 'ping'); + return http.Client().get(pingUri); } /// Initializes the connection. From 8bc47ddb182aaa2c02796fbc968dcb599bdceff4 Mon Sep 17 00:00:00 2001 From: equartey Date: Wed, 31 Aug 2022 13:17:14 -0700 Subject: [PATCH 16/47] fix: syntax errors --- .../src/graphql/ws/web_socket_message_stream_transformer.dart | 2 +- packages/api/amplify_api/test/util.dart | 2 +- 2 files changed, 2 insertions(+), 2 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 3163d52a04..50e30f7f2c 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 @@ -57,7 +57,7 @@ class WebSocketSubscriptionStreamTransformer /// [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( + WebSocketSubscriptionStreamTransformer( this.request, this.onEstablished, { required this.logger, diff --git a/packages/api/amplify_api/test/util.dart b/packages/api/amplify_api/test/util.dart index 7da7c56c1b..5539ad57e5 100644 --- a/packages/api/amplify_api/test/util.dart +++ b/packages/api/amplify_api/test/util.dart @@ -122,7 +122,7 @@ class MockWebSocketConnection extends WebSocketConnection { MockWebSocketConnection( AWSApiConfig config, AmplifyAuthProviderRepository authProviderRepo) - : super(config, authProviderRepo, logger: AmplifyLogger()); + : super(config, authProviderRepo, null, logger: AmplifyLogger()); WebSocketMessage? get lastSentMessage => sentMessages.lastOrNull; From 826d4707cf400617047b3f2c5acb859b7d47e602 Mon Sep 17 00:00:00 2001 From: equartey Date: Thu, 1 Sep 2022 11:28:48 -0700 Subject: [PATCH 17/47] fix: code review feedback 1 --- .../api/hub/api_subscription_hub_event.dart | 67 +++++++------------ .../api/amplify_api/example/lib/main.dart | 12 +--- packages/api/amplify_api/example/pubspec.yaml | 1 - packages/api/amplify_api/lib/amplify_api.dart | 1 + .../src/graphql/ws/web_socket_connection.dart | 63 +++++++++++------ ...web_socket_message_stream_transformer.dart | 6 +- packages/api/amplify_api/pubspec.yaml | 3 +- packages/api/amplify_api/test/util.dart | 4 +- 8 files changed, 77 insertions(+), 80 deletions(-) diff --git a/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart b/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart index b636aa720b..a333d110d0 100644 --- a/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart +++ b/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart @@ -20,19 +20,13 @@ import 'package:amplify_core/amplify_core.dart'; /// {@endtemplate} enum NetworkState { /// {@macro amplify_common.hub.api_network_state_connected} - connected('CONNECTED'), + connected, /// {@macro amplify_common.hub.api_network_state_disconnected} - disconnected('DISCONNECTED'), + disconnected, /// {@macro amplify_common.hub.api_network_state_failed} - failed('FAILED'); - - /// The event name. - final String state; - - /// {@macro amplify_common.hub.api_network_state} - const NetworkState(this.state); + failed, } /// {@template amplify_common.hub.api_intended_state} @@ -40,16 +34,10 @@ enum NetworkState { /// {@endtemplate} enum IntendedState { /// {@macro amplify_common.hub.api_intended_state_connected} - connected('CONNECTED'), + connected(), /// {@macro amplify_common.hub.api_intended_state_disconnected} - disconnected('DISCONNECTED'); - - /// The event name. - final String state; - - /// {@macro amplify_common.hub.api_intended_state} - const IntendedState(this.state); + disconnected(); } /// {@template amplify_common.hub.api_subscription_status} @@ -58,28 +46,22 @@ enum IntendedState { /// {@endtemplate} enum SubscriptionStatus { /// {@macro amplify_common.hub.api_subscription_status_connected} - connected('CONNECTED'), + connected(), /// {@macro amplify_common.hub.api_subscription_status_disconnected} - disconnected('DISCONNECTED'), + disconnected(), /// {@macro amplify_common.hub.api_subscription_status_connected} - connecting('CONNECTING'), + connecting(), /// {@macro amplify_common.hub.api_subscription_status_disconnected} - pendingDisconnected('PENDING_DISCONNECTED'), + pendingDisconnected(), /// {@macro amplify_common.hub.api_subscription_status_failed} - failed('FAILED'); - - /// The event name. - final String status; - - /// {@macro amplify_common.hub.api_subscription_status} - const SubscriptionStatus(this.status); + failed(); } -class SubscriptionDetails { +class SubscriptionDetails with AWSEquatable { /// {@macro amplify_common.hub.api_network_state} final NetworkState networkState; @@ -87,10 +69,16 @@ class SubscriptionDetails { final IntendedState intendedState; SubscriptionDetails(this.networkState, this.intendedState); + + @override + List get props => [intendedState, networkState]; } class SubscriptionHubEvent extends ApiHubEvent - with AWSEquatable { + with + AWSEquatable, + AWSSerializable>, + AWSDebuggable { final SubscriptionDetails details; static const String _name = 'SubscriptionHubEvent'; @@ -163,17 +151,14 @@ class SubscriptionHubEvent extends ApiHubEvent NetworkState.failed, IntendedState.disconnected)); @override - List get props => [details, payload]; + List get props => [details, status]; @override - String toString() { - return ''' -SubscriptionHubEvent: { - details: { - networkState: ${details.networkState}, - intendedState: ${details.intendedState}, - } - status: $status, -}'''; - } + String get runtimeTypeName => 'SubscriptionHubEvent'; + + @override + Map toJson() => { + 'status': status, + 'details': details.toString(), + }; } diff --git a/packages/api/amplify_api/example/lib/main.dart b/packages/api/amplify_api/example/lib/main.dart index 6d6904e07b..a9122d8e93 100644 --- a/packages/api/amplify_api/example/lib/main.dart +++ b/packages/api/amplify_api/example/lib/main.dart @@ -19,7 +19,6 @@ import 'package:amplify_api_example/rest_api_view.dart'; import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:flutter/material.dart'; -import 'package:retry/retry.dart'; import 'amplifyconfiguration.dart'; @@ -45,16 +44,9 @@ class _MyAppState extends State { } void _configureAmplify() async { - const retryStrat = RetryOptions(maxAttempts: 0); - - const subscriptionOptions = GraphQLSubscriptionOptions( - pingInterval: Duration(seconds: 30), retryOptions: retryStrat); - await Amplify.addPlugins([ AmplifyAuthCognito(), - AmplifyAPI( - subscriptionOptions: subscriptionOptions, - ), + AmplifyAPI(), ]); try { @@ -71,7 +63,7 @@ class _MyAppState extends State { HubChannel.Api, ((ApiHubEvent event) { if (event is SubscriptionHubEvent) { - print(event.toString()); + safePrint(event.toString()); } }), ); diff --git a/packages/api/amplify_api/example/pubspec.yaml b/packages/api/amplify_api/example/pubspec.yaml index e866652b86..3303c4f462 100644 --- a/packages/api/amplify_api/example/pubspec.yaml +++ b/packages/api/amplify_api/example/pubspec.yaml @@ -30,7 +30,6 @@ dependencies: flutter: sdk: flutter - retry: ^3.1.0 dev_dependencies: amplify_lints: diff --git a/packages/api/amplify_api/lib/amplify_api.dart b/packages/api/amplify_api/lib/amplify_api.dart index aa1b068e86..d6d2c83e6a 100644 --- a/packages/api/amplify_api/lib/amplify_api.dart +++ b/packages/api/amplify_api/lib/amplify_api.dart @@ -20,6 +20,7 @@ import 'package:amplify_core/amplify_core.dart'; import 'package:meta/meta.dart'; export 'package:amplify_core/src/types/api/api_types.dart'; +export 'package:retry/retry.dart'; export './model_mutations.dart'; export './model_queries.dart'; 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 c7f5381fa5..f7cc95cf19 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 @@ -56,6 +56,7 @@ class WebSocketConnection implements Closeable { StreamController? _hubEventsController; RestartableTimer? _timeoutTimer; + Client? _pingClient; Timer? _pingTimer; bool _hasNetwork = false; bool _hasConnectionBroken = false; @@ -94,13 +95,15 @@ class WebSocketConnection implements Closeable { return stream.transform(const WebSocketMessageStreamTransformer()).listen( _onData, onDone: () { - print('stream says its done!'); - _initMemo = AsyncMemoizer(); + _logger.verbose('Web socket connection done.'); + + _resetConnectionInit(); + _resetConnectionVaribles(); }, onError: (Object e) { if (!_connectionReady.isCompleted) _connectionReady.completeError(e); _connectionError( - ApiException('Connection failed.', underlyingException: e.toString()), + ApiException('Connection failed.', underlyingException: e), ); _hubEventsController?.add(SubscriptionHubEvent.failed()); @@ -170,17 +173,14 @@ class WebSocketConnection implements Closeable { void close([int closeStatus = _defaultCloseStatus]) { _logger.verbose('Closing web socket connection.'); _hubEventsController?.add(SubscriptionHubEvent.disconnected()); - 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(); + _resetConnectionVaribles(closeStatus); + _subscriptionRequests.clear(); - _network?.cancel(); _hubEventsController?.close(); + _hubEventsController = null; } /// Initiate polling @@ -240,17 +240,39 @@ class WebSocketConnection implements Closeable { // no network, can't reconnect _hubEventsController?.add(SubscriptionHubEvent.disconnected()); close(); - throw Exception([ - 'Unable to recover network connection.', - 'Web socket will closed.', - ]); + _connectionError( + ApiException( + 'Unable to recover network connection, web socket will close.', + recoverySuggestion: 'Check internet connection.', + underlyingException: e), + ); } } /// [GET] request on the configured AppSync url via the `/ping` endpoint Future _getPollRequest() { final pingUri = Uri.parse(_config.endpoint).replace(path: 'ping'); - return http.Client().get(pingUri); + return _pingClient!.get(pingUri); + } + + /// Clear varibles used to track subscriptions and other helper variables + /// related to web scoket connection. + void _resetConnectionVaribles([int closeStatus = _defaultCloseStatus]) { + final reason = + closeStatus == _defaultCloseStatus ? 'client closed' : 'unknown'; + + _channel?.sink.done.whenComplete(() => _channel = null); + _channel?.sink.close(closeStatus, reason); + _network?.cancel(); + _pingClient?.close(); + _subscription?.cancel(); + _timeoutTimer?.cancel(); + + _channel = null; + _network = null; + _pingClient = null; + _subscription = null; + _timeoutTimer = null; } /// Initializes the connection. @@ -263,10 +285,7 @@ class WebSocketConnection implements Closeable { Future _init() async { // Prep new connection with a clean slate _connectionReady = Completer(); - _timeoutTimer?.cancel(); - _subscription?.cancel(); - _network?.cancel(); - _channel?.sink.close(status.normalClosure); + _resetConnectionVaribles(); // establish hub events controller if (_hubEventsController == null || _hubEventsController!.isClosed) { @@ -283,6 +302,8 @@ class WebSocketConnection implements Closeable { await ready; + _pingClient = http.Client(); + // connection ready, start polling _startPolling(); @@ -354,9 +375,7 @@ class WebSocketConnection implements Closeable { void _cancel(String subscriptionId) { _logger.info('Attempting to cancel Operation $subscriptionId'); send(WebSocketStopMessage(id: subscriptionId)); - final request = - _subscriptionRequests.firstWhere((r) => r.id == subscriptionId); - _subscriptionRequests.remove(request); + _subscriptionRequests.removeWhere((r) => r.id == subscriptionId); if (_subscriptionRequests.isEmpty) close(status.normalClosure); } 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 50e30f7f2c..d56cf8fd4f 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 @@ -52,7 +52,7 @@ class WebSocketSubscriptionStreamTransformer /// executes when start_ack message received final void Function()? onEstablished; - final Map _establishedRequests = {}; + final Set _establishedRequests = {}; /// [request] is used to properly decode response events /// [onEstablished] is executed when start_ack message received @@ -68,9 +68,9 @@ class WebSocketSubscriptionStreamTransformer await for (var event in stream) { switch (event.messageType) { case MessageType.startAck: - if (_establishedRequests[request.id] != true) { + if (!_establishedRequests.contains(request.id)) { onEstablished?.call(); - _establishedRequests[request.id] = true; + _establishedRequests.add(request.id); } break; case MessageType.data: diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index f59755b9d7..83675f6ea7 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -25,9 +25,8 @@ dependencies: json_annotation: ^4.6.0 meta: ^1.7.0 plugin_platform_interface: ^2.0.0 - web_socket_channel: ^2.2.0 retry: ^3.1.0 - + web_socket_channel: ^2.2.0 dev_dependencies: amplify_lints: diff --git a/packages/api/amplify_api/test/util.dart b/packages/api/amplify_api/test/util.dart index 5539ad57e5..502aaf182c 100644 --- a/packages/api/amplify_api/test/util.dart +++ b/packages/api/amplify_api/test/util.dart @@ -14,6 +14,7 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:html'; import 'package:amplify_api/src/graphql/app_sync_api_key_auth_provider.dart'; import 'package:amplify_api/src/graphql/ws/web_socket_connection.dart'; @@ -122,7 +123,8 @@ class MockWebSocketConnection extends WebSocketConnection { MockWebSocketConnection( AWSApiConfig config, AmplifyAuthProviderRepository authProviderRepo) - : super(config, authProviderRepo, null, logger: AmplifyLogger()); + : super(config, authProviderRepo, const GraphQLSubscriptionOptions(), + logger: AmplifyLogger()); WebSocketMessage? get lastSentMessage => sentMessages.lastOrNull; From b786902d970441995346f2172fc96a957b7e5efa Mon Sep 17 00:00:00 2001 From: equartey Date: Thu, 1 Sep 2022 15:02:10 -0700 Subject: [PATCH 18/47] fix: export retry package --- packages/amplify_core/lib/src/types/api/api_types.dart | 3 +++ packages/api/amplify_api/lib/amplify_api.dart | 1 - .../amplify_api/lib/src/graphql/ws/web_socket_connection.dart | 2 -- packages/api/amplify_api/pubspec.yaml | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) 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 d3d26e8feb..766da4b7bc 100644 --- a/packages/amplify_core/lib/src/types/api/api_types.dart +++ b/packages/amplify_core/lib/src/types/api/api_types.dart @@ -13,6 +13,9 @@ * permissions and limitations under the License. */ +// Packages +export 'package:retry/retry.dart'; + // API Authorization export 'auth/api_auth_provider.dart'; export 'auth/api_authorization_type.dart'; diff --git a/packages/api/amplify_api/lib/amplify_api.dart b/packages/api/amplify_api/lib/amplify_api.dart index d6d2c83e6a..aa1b068e86 100644 --- a/packages/api/amplify_api/lib/amplify_api.dart +++ b/packages/api/amplify_api/lib/amplify_api.dart @@ -20,7 +20,6 @@ import 'package:amplify_core/amplify_core.dart'; import 'package:meta/meta.dart'; export 'package:amplify_core/src/types/api/api_types.dart'; -export 'package:retry/retry.dart'; export './model_mutations.dart'; export './model_queries.dart'; 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 f7cc95cf19..4bf19f06e2 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 @@ -23,7 +23,6 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; import 'package:meta/meta.dart'; -import 'package:retry/retry.dart'; import 'package:web_socket_channel/status.dart' as status; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -124,7 +123,6 @@ class WebSocketConnection implements Closeable { return Connectivity() .onConnectivityChanged .listen((ConnectivityResult connectivityResult) async { - print('Connection: $connectivityResult'); switch (connectivityResult) { case ConnectivityResult.ethernet: case ConnectivityResult.mobile: diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index 83675f6ea7..cc2d53a34d 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -25,7 +25,6 @@ dependencies: json_annotation: ^4.6.0 meta: ^1.7.0 plugin_platform_interface: ^2.0.0 - retry: ^3.1.0 web_socket_channel: ^2.2.0 dev_dependencies: From 2abff51a9c7dd62ecb2e0a89a17d7a0a0026fa74 Mon Sep 17 00:00:00 2001 From: equartey Date: Thu, 8 Sep 2022 11:09:26 -0500 Subject: [PATCH 19/47] fix: Hub Event errors --- .../src/types/api/hub/api_subscription_hub_event.dart | 11 ++++++++--- .../macos/Flutter/GeneratedPluginRegistrant.swift | 2 ++ .../windows/flutter/generated_plugin_registrant.cc | 3 +++ .../example/windows/flutter/generated_plugins.cmake | 1 + .../lib/src/graphql/ws/web_socket_connection.dart | 10 +++++++--- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart b/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart index a333d110d0..9c9a467032 100644 --- a/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart +++ b/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart @@ -26,7 +26,7 @@ enum NetworkState { disconnected, /// {@macro amplify_common.hub.api_network_state_failed} - failed, + failed; } /// {@template amplify_common.hub.api_intended_state} @@ -72,6 +72,11 @@ class SubscriptionDetails with AWSEquatable { @override List get props => [intendedState, networkState]; + + Map toJson() => { + 'networkState': networkState.name, + 'intendedState': intendedState.name, + }; } class SubscriptionHubEvent extends ApiHubEvent @@ -158,7 +163,7 @@ class SubscriptionHubEvent extends ApiHubEvent @override Map toJson() => { - 'status': status, - 'details': details.toString(), + 'status': status.name, + 'details': details.toJson(), }; } diff --git a/packages/api/amplify_api/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/api/amplify_api/example/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817a52..be95aee804 100644 --- a/packages/api/amplify_api/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/api/amplify_api/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import connectivity_plus_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) } diff --git a/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.cc b/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.cc index 8b6d4680af..8083d749b1 100644 --- a/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); } diff --git a/packages/api/amplify_api/example/windows/flutter/generated_plugins.cmake b/packages/api/amplify_api/example/windows/flutter/generated_plugins.cmake index b93c4c30c1..8cf5d42678 100644 --- a/packages/api/amplify_api/example/windows/flutter/generated_plugins.cmake +++ b/packages/api/amplify_api/example/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST 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 4bf19f06e2..93ad97848b 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 @@ -194,8 +194,9 @@ class WebSocketConnection implements Closeable { /// reconnection through retry/back off. Future _poll() async { try { + if (_pingClient == null) return; final res = await _getPollRequest(); - if (res.body != 'healthy') { + if (res!.body != 'healthy') { throw Exception([ 'Subscription connection broken.', 'AppSync status check returned: ', @@ -221,6 +222,8 @@ class WebSocketConnection implements Closeable { /// Attempts to connect to the configured graphql endpoint. /// Upon successful ping, a new websocket connection is initialized. Future _retry() async { + if (_pingClient == null) return; + RetryOptions retryStrat = _subscriptionOptions?.retryOptions ?? const RetryOptions(); @@ -230,7 +233,7 @@ class WebSocketConnection implements Closeable { await retryStrat.retry( // Make a GET request - () => _getPollRequest().timeout(_defaultRetryTimeout)); + () => _getPollRequest()!.timeout(_defaultRetryTimeout)); // we can reach AppSync, proceede with reconnect _init(); @@ -248,7 +251,8 @@ class WebSocketConnection implements Closeable { } /// [GET] request on the configured AppSync url via the `/ping` endpoint - Future _getPollRequest() { + Future? _getPollRequest() { + if (_pingClient == null) return null; final pingUri = Uri.parse(_config.endpoint).replace(path: 'ping'); return _pingClient!.get(pingUri); } From 80aa0d5eb9bc3c0a3461aacb7277165195d0ed89 Mon Sep 17 00:00:00 2001 From: equartey Date: Thu, 8 Sep 2022 13:15:15 -0500 Subject: [PATCH 20/47] fix: typos & PR comments --- .../graphql/graphql_subscription_options.dart | 3 +- .../api/hub/api_subscription_hub_event.dart | 20 ++++++------ .../src/graphql/ws/web_socket_connection.dart | 31 ++++++++++--------- packages/api/amplify_api/test/util.dart | 1 - 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/packages/amplify_core/lib/src/types/api/graphql/graphql_subscription_options.dart b/packages/amplify_core/lib/src/types/api/graphql/graphql_subscription_options.dart index a7b3840dce..0b7555425e 100644 --- a/packages/amplify_core/lib/src/types/api/graphql/graphql_subscription_options.dart +++ b/packages/amplify_core/lib/src/types/api/graphql/graphql_subscription_options.dart @@ -15,12 +15,13 @@ import 'package:retry/retry.dart'; -/// Confiuration options for GraphQL Subscriptions and their websockets. +/// Configuration options for GraphQL Subscriptions and their WebSockets. class GraphQLSubscriptionOptions { /// Configure the ping interval for AppSync polling for subscription connections. final Duration? pingInterval; /// Configure the exponential retry strategy options + /// see: https://pub.dev/documentation/retry/latest/retry/RetryOptions-class.html final RetryOptions? retryOptions; const GraphQLSubscriptionOptions({this.pingInterval, this.retryOptions}); diff --git a/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart b/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart index 9c9a467032..6ce576cdf9 100644 --- a/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart +++ b/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart @@ -119,37 +119,37 @@ class SubscriptionHubEvent extends ApiHubEvent return SubscriptionStatus.disconnected; } - /// {@template amplify_common.hub.api_subscrption_connected} - /// Emitted when a GraphQL subscrption is connected. + /// {@template amplify_common.hub.api_subscription_connected} + /// Emitted when a GraphQL subscription is connected. /// {@endtemplate} SubscriptionHubEvent.connected() : this._(SubscriptionDetails( NetworkState.connected, IntendedState.connected)); - /// {@template amplify_common.hub.api_subscrption_connected} - /// Emitted when a GraphQL subscrption is connecting/reconnecting. + /// {@template amplify_common.hub.api_subscription_connected} + /// Emitted when a GraphQL subscription is connecting/reconnecting. /// {@endtemplate} SubscriptionHubEvent.connecting() : this._(SubscriptionDetails( NetworkState.disconnected, IntendedState.connected)); - /// {@template amplify_common.hub.api_subscrption_disconnected} - /// Emitted when a GraphQL subscrption connection has disconnected. + /// {@template amplify_common.hub.api_subscription_disconnected} + /// Emitted when a GraphQL subscription connection has disconnected. /// {@endtemplate} SubscriptionHubEvent.disconnected() : this._(SubscriptionDetails( NetworkState.disconnected, IntendedState.disconnected)); - /// {@template amplify_common.hub.api_subscrption_pending_disconnect} - /// Emitted when a GraphQL subscrption connection is pending disconnect, + /// {@template amplify_common.hub.api_subscription_pending_disconnect} + /// Emitted when a GraphQL subscription connection is pending disconnect, /// but should exist. /// {@endtemplate} SubscriptionHubEvent.pendingDisconnect() : this._(SubscriptionDetails( NetworkState.connected, IntendedState.disconnected)); - /// {@template amplify_common.hub.api_subscrption_failed} - /// Emitted when a GraphQL subscrption connection has failed. + /// {@template amplify_common.hub.api_subscription_failed} + /// Emitted when a GraphQL subscription connection has failed. /// {@endtemplate} SubscriptionHubEvent.failed() : this._(SubscriptionDetails( 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 93ad97848b..955c16bbe0 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 @@ -21,7 +21,6 @@ import 'package:amplify_core/amplify_core.dart'; import 'package:async/async.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:http/http.dart' as http; -import 'package:http/http.dart'; import 'package:meta/meta.dart'; import 'package:web_socket_channel/status.dart' as status; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -31,6 +30,8 @@ import 'web_socket_types.dart'; /// 1001, going away const _defaultCloseStatus = status.goingAway; +const Duration _defaultPingInterval = Duration(seconds: 30); +const Duration _defaultRetryTimeout = Duration(seconds: 5); /// {@template amplify_api.ws.web_socket_connection} /// Manages connection with an AppSync backend and subscription routing. @@ -55,17 +56,17 @@ class WebSocketConnection implements Closeable { StreamController? _hubEventsController; RestartableTimer? _timeoutTimer; - Client? _pingClient; + http.Client? _pingClient; Timer? _pingTimer; bool _hasNetwork = false; bool _hasConnectionBroken = false; final Set> _subscriptionRequests = HashSet( - hashCode: ((p0) => p0.id.hashCode), equals: (x, y) => x.id == y.id); + hashCode: ((p0) => p0.id.hashCode), + equals: (x, y) => x.id == y.id, + ); /// Subscription options final GraphQLSubscriptionOptions? _subscriptionOptions; - final Duration _defaultPingInterval = const Duration(seconds: 30); - final Duration _defaultRetryTimeout = const Duration(seconds: 5); // Re-broadcasts incoming messages for child streams (single GraphQL subscriptions). // start_ack, data, error @@ -97,7 +98,7 @@ class WebSocketConnection implements Closeable { _logger.verbose('Web socket connection done.'); _resetConnectionInit(); - _resetConnectionVaribles(); + _resetConnectionVariables(); }, onError: (Object e) { if (!_connectionReady.isCompleted) _connectionReady.completeError(e); @@ -174,7 +175,7 @@ class WebSocketConnection implements Closeable { _rebroadcastController.close(); _resetConnectionInit(); - _resetConnectionVaribles(closeStatus); + _resetConnectionVariables(closeStatus); _subscriptionRequests.clear(); _hubEventsController?.close(); @@ -224,18 +225,18 @@ class WebSocketConnection implements Closeable { Future _retry() async { if (_pingClient == null) return; - RetryOptions retryStrat = + RetryOptions retryStrategy = _subscriptionOptions?.retryOptions ?? const RetryOptions(); try { _hasConnectionBroken = false; _hubEventsController?.add(SubscriptionHubEvent.connecting()); - await retryStrat.retry( + await retryStrategy.retry( // Make a GET request () => _getPollRequest()!.timeout(_defaultRetryTimeout)); - // we can reach AppSync, proceede with reconnect + // we can reach AppSync, precede with reconnect _init(); } on Exception catch (e) { // no network, can't reconnect @@ -251,15 +252,15 @@ class WebSocketConnection implements Closeable { } /// [GET] request on the configured AppSync url via the `/ping` endpoint - Future? _getPollRequest() { + Future? _getPollRequest() { if (_pingClient == null) return null; final pingUri = Uri.parse(_config.endpoint).replace(path: 'ping'); return _pingClient!.get(pingUri); } - /// Clear varibles used to track subscriptions and other helper variables - /// related to web scoket connection. - void _resetConnectionVaribles([int closeStatus = _defaultCloseStatus]) { + /// Clear variables used to track subscriptions and other helper variables + /// related to web socket connection. + void _resetConnectionVariables([int closeStatus = _defaultCloseStatus]) { final reason = closeStatus == _defaultCloseStatus ? 'client closed' : 'unknown'; @@ -287,7 +288,7 @@ class WebSocketConnection implements Closeable { Future _init() async { // Prep new connection with a clean slate _connectionReady = Completer(); - _resetConnectionVaribles(); + _resetConnectionVariables(); // establish hub events controller if (_hubEventsController == null || _hubEventsController!.isClosed) { diff --git a/packages/api/amplify_api/test/util.dart b/packages/api/amplify_api/test/util.dart index 502aaf182c..1cbd968579 100644 --- a/packages/api/amplify_api/test/util.dart +++ b/packages/api/amplify_api/test/util.dart @@ -14,7 +14,6 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:html'; import 'package:amplify_api/src/graphql/app_sync_api_key_auth_provider.dart'; import 'package:amplify_api/src/graphql/ws/web_socket_connection.dart'; From 7fc1ceacf7fc5885dd16576dc8c1391f44633d79 Mon Sep 17 00:00:00 2001 From: equartey Date: Thu, 8 Sep 2022 15:28:46 -0500 Subject: [PATCH 21/47] fix: review comments --- .../lib/src/types/api/api_types.dart | 2 +- .../api/hub/api_subscription_hub_event.dart | 24 ++++--- .../api/amplify_api/example/lib/main.dart | 2 +- .../src/graphql/ws/web_socket_connection.dart | 70 ++++++++++--------- ...web_socket_message_stream_transformer.dart | 2 +- 5 files changed, 56 insertions(+), 44 deletions(-) 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 766da4b7bc..ef0ef94391 100644 --- a/packages/amplify_core/lib/src/types/api/api_types.dart +++ b/packages/amplify_core/lib/src/types/api/api_types.dart @@ -14,7 +14,7 @@ */ // Packages -export 'package:retry/retry.dart'; +export 'package:retry/retry.dart' show RetryOptions; // API Authorization export 'auth/api_auth_provider.dart'; diff --git a/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart b/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart index 6ce576cdf9..4c7c6bc24c 100644 --- a/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart +++ b/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart @@ -34,10 +34,10 @@ enum NetworkState { /// {@endtemplate} enum IntendedState { /// {@macro amplify_common.hub.api_intended_state_connected} - connected(), + connected, /// {@macro amplify_common.hub.api_intended_state_disconnected} - disconnected(); + disconnected; } /// {@template amplify_common.hub.api_subscription_status} @@ -46,22 +46,26 @@ enum IntendedState { /// {@endtemplate} enum SubscriptionStatus { /// {@macro amplify_common.hub.api_subscription_status_connected} - connected(), + connected, /// {@macro amplify_common.hub.api_subscription_status_disconnected} - disconnected(), + disconnected, /// {@macro amplify_common.hub.api_subscription_status_connected} - connecting(), + connecting, /// {@macro amplify_common.hub.api_subscription_status_disconnected} - pendingDisconnected(), + pendingDisconnected, /// {@macro amplify_common.hub.api_subscription_status_failed} - failed(); + failed; } -class SubscriptionDetails with AWSEquatable { +class SubscriptionDetails + with + AWSEquatable, + AWSSerializable>, + AWSDebuggable { /// {@macro amplify_common.hub.api_network_state} final NetworkState networkState; @@ -73,10 +77,14 @@ class SubscriptionDetails with AWSEquatable { @override List get props => [intendedState, networkState]; + @override Map toJson() => { 'networkState': networkState.name, 'intendedState': intendedState.name, }; + + @override + String get runtimeTypeName => 'SubscriptionDetails'; } class SubscriptionHubEvent extends ApiHubEvent diff --git a/packages/api/amplify_api/example/lib/main.dart b/packages/api/amplify_api/example/lib/main.dart index a9122d8e93..fa37c6200e 100644 --- a/packages/api/amplify_api/example/lib/main.dart +++ b/packages/api/amplify_api/example/lib/main.dart @@ -63,7 +63,7 @@ class _MyAppState extends State { HubChannel.Api, ((ApiHubEvent event) { if (event is SubscriptionHubEvent) { - safePrint(event.toString()); + safePrint(event); } }), ); 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 955c16bbe0..38288b6ac7 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 @@ -58,6 +58,7 @@ class WebSocketConnection implements Closeable { http.Client? _pingClient; Timer? _pingTimer; + late Uri? _pingUri; bool _hasNetwork = false; bool _hasConnectionBroken = false; final Set> _subscriptionRequests = HashSet( @@ -105,12 +106,12 @@ class WebSocketConnection implements Closeable { _connectionError( ApiException('Connection failed.', underlyingException: e), ); - _hubEventsController?.add(SubscriptionHubEvent.failed()); + _hubEventsController!.add(SubscriptionHubEvent.failed()); - if (_hasNetwork && - _hasConnectionBroken && - _subscriptionRequests.isNotEmpty) { + if (_hasNetwork && _subscriptionRequests.isNotEmpty) { _retry(); + } else { + _hubEventsController?.add(SubscriptionHubEvent.failed()); } }, // Todo(equartey): investigate why this breaks onDone & onError when an error occurs. @@ -135,7 +136,7 @@ class WebSocketConnection implements Closeable { case ConnectivityResult.none: _hasNetwork = false; _hasConnectionBroken = true; - _hubEventsController?.add(SubscriptionHubEvent.connecting()); + _hubEventsController!.add(SubscriptionHubEvent.connecting()); break; default: break; @@ -171,14 +172,14 @@ class WebSocketConnection implements Closeable { @override void close([int closeStatus = _defaultCloseStatus]) { _logger.verbose('Closing web socket connection.'); - _hubEventsController?.add(SubscriptionHubEvent.disconnected()); + _hubEventsController!.add(SubscriptionHubEvent.disconnected()); _rebroadcastController.close(); _resetConnectionInit(); _resetConnectionVariables(closeStatus); _subscriptionRequests.clear(); - _hubEventsController?.close(); + _hubEventsController!.close(); _hubEventsController = null; } @@ -198,13 +199,16 @@ class WebSocketConnection implements Closeable { if (_pingClient == null) return; final res = await _getPollRequest(); if (res!.body != 'healthy') { - throw Exception([ - 'Subscription connection broken.', - 'AppSync status check returned: ', - res.body - ]); + throw ApiException( + 'Subscription connection broken. AppSync status check returned: ${res.body}', + recoverySuggestion: + 'Unable to ping the configured AppSync URL. Check internet connection or the configured AppSync URL', + ); } } on Exception catch (e) { + if (e is ApiException) { + _logger.error(e.message, e.recoverySuggestion); + } _pingTimer?.cancel(); _timeoutTimer?.cancel(); @@ -215,7 +219,7 @@ class WebSocketConnection implements Closeable { if (_hasNetwork) { _retry(); } else { - _hubEventsController?.add(SubscriptionHubEvent.connecting()); + _hubEventsController!.add(SubscriptionHubEvent.connecting()); } } } @@ -230,7 +234,7 @@ class WebSocketConnection implements Closeable { try { _hasConnectionBroken = false; - _hubEventsController?.add(SubscriptionHubEvent.connecting()); + _hubEventsController!.add(SubscriptionHubEvent.connecting()); await retryStrategy.retry( // Make a GET request @@ -240,7 +244,7 @@ class WebSocketConnection implements Closeable { _init(); } on Exception catch (e) { // no network, can't reconnect - _hubEventsController?.add(SubscriptionHubEvent.disconnected()); + _hubEventsController!.add(SubscriptionHubEvent.disconnected()); close(); _connectionError( ApiException( @@ -254,8 +258,8 @@ class WebSocketConnection implements Closeable { /// [GET] request on the configured AppSync url via the `/ping` endpoint Future? _getPollRequest() { if (_pingClient == null) return null; - final pingUri = Uri.parse(_config.endpoint).replace(path: 'ping'); - return _pingClient!.get(pingUri); + _pingUri ??= Uri.parse(_config.endpoint).replace(path: 'ping'); + return _pingClient!.get(_pingUri!); } /// Clear variables used to track subscriptions and other helper variables @@ -264,18 +268,19 @@ class WebSocketConnection implements Closeable { final reason = closeStatus == _defaultCloseStatus ? 'client closed' : 'unknown'; - _channel?.sink.done.whenComplete(() => _channel = null); - _channel?.sink.close(closeStatus, reason); - _network?.cancel(); - _pingClient?.close(); - _subscription?.cancel(); - _timeoutTimer?.cancel(); + _channel?.sink.done.whenComplete(() { + _network?.cancel(); + _pingClient?.close(); + _subscription?.cancel(); + _timeoutTimer?.cancel(); - _channel = null; - _network = null; - _pingClient = null; - _subscription = null; - _timeoutTimer = null; + _channel = null; + _network = null; + _pingClient = null; + _subscription = null; + _timeoutTimer = null; + }); + _channel?.sink.close(closeStatus, reason); } /// Initializes the connection. @@ -295,7 +300,7 @@ class WebSocketConnection implements Closeable { _hubEventsController = StreamController.broadcast(); Amplify.Hub.addChannel(HubChannel.Api, _hubEventsController!.stream); } - _hubEventsController?.add(SubscriptionHubEvent.connecting()); + _hubEventsController!.add(SubscriptionHubEvent.connecting()); final connectionUri = await generateConnectionUri(_config, _authProviderRepo); @@ -318,7 +323,7 @@ class WebSocketConnection implements Closeable { send(subscriptionRegistrationMessage); } - _hubEventsController?.add(SubscriptionHubEvent.connected()); + _hubEventsController!.add(SubscriptionHubEvent.connected()); } Future _sendSubscriptionRegistrationMessage( @@ -368,7 +373,7 @@ class WebSocketConnection implements Closeable { _sendSubscriptionRegistrationMessage(request).catchError((Object e) { controller.addError(e); - _hubEventsController?.add(SubscriptionHubEvent.failed()); + _hubEventsController!.add(SubscriptionHubEvent.failed()); }); return controller.stream; @@ -392,7 +397,7 @@ class WebSocketConnection implements Closeable { /// Times out the connection (usually if a keep alive has not been received in time). void _timeout(Duration timeoutDuration) { _timeoutTimer?.cancel(); - _hubEventsController?.add(SubscriptionHubEvent.disconnected()); + _hubEventsController!.add(SubscriptionHubEvent.disconnected()); _rebroadcastController.addError( TimeoutException( 'Connection timeout', @@ -443,7 +448,6 @@ class WebSocketConnection implements Closeable { } // Re-broadcast other message types related to single subscriptions. - _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 d56cf8fd4f..2647ad9805 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 @@ -52,7 +52,7 @@ class WebSocketSubscriptionStreamTransformer /// executes when start_ack message received final void Function()? onEstablished; - final Set _establishedRequests = {}; + static const Set _establishedRequests = {}; /// [request] is used to properly decode response events /// [onEstablished] is executed when start_ack message received From ff85ce28d40a5304df62a985f64c297a5e033835 Mon Sep 17 00:00:00 2001 From: equartey Date: Fri, 9 Sep 2022 10:44:46 -0500 Subject: [PATCH 22/47] fix: hub events types --- .../api/hub/api_subscription_hub_event.dart | 4 +- .../src/graphql/ws/web_socket_connection.dart | 42 +++++++++---------- ...web_socket_message_stream_transformer.dart | 6 +-- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart b/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart index 4c7c6bc24c..f37ba5a3d5 100644 --- a/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart +++ b/packages/amplify_core/lib/src/types/api/hub/api_subscription_hub_event.dart @@ -61,7 +61,7 @@ enum SubscriptionStatus { failed; } -class SubscriptionDetails +class SubscriptionDetails extends ApiHubEventPayload with AWSEquatable, AWSSerializable>, @@ -96,7 +96,7 @@ class SubscriptionHubEvent extends ApiHubEvent static const String _name = 'SubscriptionHubEvent'; - SubscriptionHubEvent._(this.details) : super(_name); + SubscriptionHubEvent._(this.details) : super(_name, payload: details); /// {@template amplify_common.hub.api_subscription_status} /// An overall status for GraphQL subscription connection, determined by the 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 38288b6ac7..1aba45464b 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 @@ -53,7 +53,8 @@ class WebSocketConnection implements Closeable { WebSocketChannel? _channel; StreamSubscription? _subscription; StreamSubscription? _network; - StreamController? _hubEventsController; + final StreamController _hubEventsController = + StreamController.broadcast(); RestartableTimer? _timeoutTimer; http.Client? _pingClient; @@ -77,6 +78,7 @@ class WebSocketConnection implements Closeable { // Manage initial connection state. var _initMemo = AsyncMemoizer(); + var _hubMemo = AsyncMemoizer(); Completer _connectionReady = Completer(); /// Fires when the connection is ready to be listened to after the first @@ -102,11 +104,10 @@ class WebSocketConnection implements Closeable { _resetConnectionVariables(); }, onError: (Object e) { - if (!_connectionReady.isCompleted) _connectionReady.completeError(e); + _hubEventsController.add(SubscriptionHubEvent.failed()); _connectionError( ApiException('Connection failed.', underlyingException: e), ); - _hubEventsController!.add(SubscriptionHubEvent.failed()); if (_hasNetwork && _subscriptionRequests.isNotEmpty) { _retry(); @@ -136,7 +137,7 @@ class WebSocketConnection implements Closeable { case ConnectivityResult.none: _hasNetwork = false; _hasConnectionBroken = true; - _hubEventsController!.add(SubscriptionHubEvent.connecting()); + _hubEventsController.add(SubscriptionHubEvent.connecting()); break; default: break; @@ -157,7 +158,9 @@ class WebSocketConnection implements Closeable { } void _connectionError(ApiException exception) { - _connectionReady.completeError(exception); + if (!_connectionReady.isCompleted) { + _connectionReady.completeError(exception); + } _channel?.sink.close(); _resetConnectionInit(); } @@ -172,15 +175,14 @@ class WebSocketConnection implements Closeable { @override void close([int closeStatus = _defaultCloseStatus]) { _logger.verbose('Closing web socket connection.'); - _hubEventsController!.add(SubscriptionHubEvent.disconnected()); + _hubEventsController.add(SubscriptionHubEvent.disconnected()); _rebroadcastController.close(); _resetConnectionInit(); _resetConnectionVariables(closeStatus); _subscriptionRequests.clear(); - _hubEventsController!.close(); - _hubEventsController = null; + _hubEventsController.close(); } /// Initiate polling @@ -219,7 +221,7 @@ class WebSocketConnection implements Closeable { if (_hasNetwork) { _retry(); } else { - _hubEventsController!.add(SubscriptionHubEvent.connecting()); + _hubEventsController.add(SubscriptionHubEvent.connecting()); } } } @@ -234,7 +236,7 @@ class WebSocketConnection implements Closeable { try { _hasConnectionBroken = false; - _hubEventsController!.add(SubscriptionHubEvent.connecting()); + _hubEventsController.add(SubscriptionHubEvent.connecting()); await retryStrategy.retry( // Make a GET request @@ -244,7 +246,7 @@ class WebSocketConnection implements Closeable { _init(); } on Exception catch (e) { // no network, can't reconnect - _hubEventsController!.add(SubscriptionHubEvent.disconnected()); + _hubEventsController.add(SubscriptionHubEvent.disconnected()); close(); _connectionError( ApiException( @@ -256,8 +258,7 @@ class WebSocketConnection implements Closeable { } /// [GET] request on the configured AppSync url via the `/ping` endpoint - Future? _getPollRequest() { - if (_pingClient == null) return null; + Future _getPollRequest() { _pingUri ??= Uri.parse(_config.endpoint).replace(path: 'ping'); return _pingClient!.get(_pingUri!); } @@ -296,11 +297,10 @@ class WebSocketConnection implements Closeable { _resetConnectionVariables(); // establish hub events controller - if (_hubEventsController == null || _hubEventsController!.isClosed) { - _hubEventsController = StreamController.broadcast(); - Amplify.Hub.addChannel(HubChannel.Api, _hubEventsController!.stream); - } - _hubEventsController!.add(SubscriptionHubEvent.connecting()); + _hubMemo.runOnce(() => + Amplify.Hub.addChannel(HubChannel.Api, _hubEventsController.stream)); + + _hubEventsController.add(SubscriptionHubEvent.connecting()); final connectionUri = await generateConnectionUri(_config, _authProviderRepo); @@ -323,7 +323,7 @@ class WebSocketConnection implements Closeable { send(subscriptionRegistrationMessage); } - _hubEventsController!.add(SubscriptionHubEvent.connected()); + _hubEventsController.add(SubscriptionHubEvent.connected()); } Future _sendSubscriptionRegistrationMessage( @@ -373,7 +373,7 @@ class WebSocketConnection implements Closeable { _sendSubscriptionRegistrationMessage(request).catchError((Object e) { controller.addError(e); - _hubEventsController!.add(SubscriptionHubEvent.failed()); + _hubEventsController.add(SubscriptionHubEvent.failed()); }); return controller.stream; @@ -397,7 +397,7 @@ class WebSocketConnection implements Closeable { /// Times out the connection (usually if a keep alive has not been received in time). void _timeout(Duration timeoutDuration) { _timeoutTimer?.cancel(); - _hubEventsController!.add(SubscriptionHubEvent.disconnected()); + _hubEventsController.add(SubscriptionHubEvent.disconnected()); _rebroadcastController.addError( TimeoutException( 'Connection timeout', 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 2647ad9805..e0d1c20759 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 @@ -52,7 +52,7 @@ class WebSocketSubscriptionStreamTransformer /// executes when start_ack message received final void Function()? onEstablished; - static const Set _establishedRequests = {}; + bool _establishedRequest = false; /// [request] is used to properly decode response events /// [onEstablished] is executed when start_ack message received @@ -68,9 +68,9 @@ class WebSocketSubscriptionStreamTransformer await for (var event in stream) { switch (event.messageType) { case MessageType.startAck: - if (!_establishedRequests.contains(request.id)) { + if (!_establishedRequest) { onEstablished?.call(); - _establishedRequests.add(request.id); + _establishedRequest = true; } break; case MessageType.data: From 9ddea6b4cc98be353d2e2560aee927da63da6377 Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Wed, 15 Jun 2022 11:35:34 -0800 Subject: [PATCH 23/47] 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 { Category get category => Category.api; // ====== GraphQL ======= - GraphQLOperation query({required GraphQLRequest request}) { - return plugins.length == 1 - ? plugins[0].query(request: request) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation> query( + {required GraphQLRequest request}) => + defaultPlugin.query(request: request); - GraphQLOperation mutate({required GraphQLRequest request}) { - return plugins.length == 1 - ? plugins[0].mutate(request: request) - : throw _pluginNotAddedException('Api'); - } + CancelableOperation> mutate( + {required GraphQLRequest 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 { Stream> subscribe( GraphQLRequest 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 delete( + String path, { + Map? headers, + HttpPayload? body, + Map? 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 get( + String path, { + Map? headers, + Map? 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 head( + String path, { + Map? headers, + Map? 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 patch( + String path, { + Map? headers, + HttpPayload? body, + Map? 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 post( + String path, { + Map? headers, + HttpPayload? body, + Map? 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 put( + String path, { + Map? headers, + HttpPayload? body, + Map? 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 query({required GraphQLRequest request}) { + CancelableOperation> query( + {required GraphQLRequest request}) { throw UnimplementedError('query() has not been implemented.'); } - GraphQLOperation mutate({required GraphQLRequest request}) { + CancelableOperation> mutate( + {required GraphQLRequest 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 delete( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 get( + String path, { + Map? headers, + Map? queryParameters, + String? apiName, + }) { + throw UnimplementedError('get() has not been implemented'); } - RestOperation post({required RestOptions restOptions}) { - throw UnimplementedError('post has not been implemented.'); + CancelableOperation head( + String path, { + Map? headers, + Map? queryParameters, + String? apiName, + }) { + throw UnimplementedError('head() has not been implemented'); } - RestOperation delete({required RestOptions restOptions}) { - throw UnimplementedError('delete has not been implemented.'); + CancelableOperation patch( + String path, { + HttpPayload? body, + Map? headers, + Map? queryParameters, + String? apiName, + }) { + throw UnimplementedError('patch() has not been implemented'); } - RestOperation head({required RestOptions restOptions}) { - throw UnimplementedError('head has not been implemented.'); + CancelableOperation post( + String path, { + HttpPayload? body, + Map? headers, + Map? queryParameters, + String? apiName, + }) { + throw UnimplementedError('post() has not been implemented'); } - RestOperation patch({required RestOptions restOptions}) { - throw UnimplementedError('patch has not been implemented.'); + CancelableOperation put( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 4d8dae1e20..004711395e 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,24 +19,16 @@ 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( super.message, { super.recoverySuggestion, super.underlyingException, - this.httpStatusCode, }); /// Constructor for down casting an AmplifyException to this exception ApiException._private( AmplifyException exception, - this.httpStatusCode, ) : super( exception.message, recoverySuggestion: exception.recoverySuggestion, @@ -53,7 +45,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 { - final Function cancel; - final Future> 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 on CancelableOperation> { + @Deprecated('use .value instead') + Future> 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> { + 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) { + return HttpPayload.bytes(body); + } + if (body is Stream>) { + return HttpPayload.streaming(body); + } + if (body is Map) { + 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 body) : super(Stream.value(body)); + + /// A form-encoded body of `key=value` pairs. + HttpPayload.formFields(Map 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> 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 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 response; - - const RestOperation({required this.response, required this.cancel}); +/// Allows callers to synchronously get unstreamed response with the decoded body. +extension RestOperation on CancelableOperation { + Future 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 { - /// The response status code. - final int statusCode; - - /// The response headers. - /// - /// Will be `null` if unavailable from the platform. - final Map? 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 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 { String _result = ''; void Function()? _unsubscribe; - late GraphQLOperation _lastOperation; + late CancelableOperation _lastOperation; Future 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 { } 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 { late TextEditingController _apiPathController; - late RestOperation _lastRestOperation; + late CancelableOperation _lastRestOperation; @override void initState() { @@ -39,18 +38,16 @@ class _RestApiViewState extends State { 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 { 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 { 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 { 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 { 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 { 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 query({required GraphQLRequest request}) { - Future> response = + CancelableOperation> query( + {required GraphQLRequest request}) { + Future> responseFuture = _getMethodChannelResponse(methodName: 'query', request: request); - - //TODO: Cancel implementation will be added along with REST API as it is shared - GraphQLOperation result = GraphQLOperation( - cancel: () => cancelRequest(request.id), - response: response, - ); - - return result; + return CancelableOperation.fromFuture(responseFuture); } @override - GraphQLOperation mutate({required GraphQLRequest request}) { - Future> response = + CancelableOperation> mutate( + {required GraphQLRequest request}) { + Future> responseFuture = _getMethodChannelResponse(methodName: 'mutate', request: request); - - //TODO: Cancel implementation will be added along with REST API as it is shared - GraphQLOperation result = GraphQLOperation( - 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 futureResponse = - _callNativeRestMethod(methodName, cancelToken, restOptions); + Future _restResponseHelper({ + required String methodName, + required String path, + required String cancelToken, + HttpPayload? body, + Map? headers, + Map? queryParameters, + String? apiName, + }) async { + Uint8List? bodyBytes; + if (body != null) { + final completer = Completer(); + 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 _restFunctionHelper({ + required String methodName, + required String path, + HttpPayload? body, + Map? headers, + Map? 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 _callNativeRestMethod( + Future _callNativeRestMethod( String methodName, String cancelToken, RestOptions restOptions) async { // Prepare map input Map inputsMap = {}; @@ -284,55 +325,125 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { } } - bool _shouldThrow(int statusCode) { - return statusCode < 200 || statusCode > 299; - } - - RestResponse _formatRestResponse(Map res) { + AWSStreamedHttpResponse _formatRestResponse(Map res) { final statusCode = res['statusCode'] as int; - final headers = res['headers'] as Map?; - final response = RestResponse( - data: res['data'] as Uint8List?, - headers: headers?.cast(), - 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(); + 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 get( + String path, { + Map? headers, + Map? 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 put( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 post( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 delete( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 head( + String path, { + Map? headers, + Map? 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 patch( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 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()); +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 _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 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 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 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 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 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), '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 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), '{"foo":"bar"}'); return { - 'statusCode': statusCode, - 'headers': {}, - '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 = 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 { 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 8e52487590b294c6adfeff8eb96e80d8637dd0a5 Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Thu, 23 Jun 2022 11:39:46 -0500 Subject: [PATCH 24/47] 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 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 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 _authProviders = {}; + + /// {@macro amplify_api.amplify_api_dart} + AmplifyAPIDart({ + List authProviders = const [], + this.modelProvider, + }) : super.protected() { + authProviders.forEach(registerAuthProvider); + } + + @override + Future 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 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 codec = _NativeApiBridgeCodec(); + + Future addPlugin(List arg_authProvidersList) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.NativeApiBridge.addPlugin', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_authProvidersList]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + 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 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 = - (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) { + 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 authProvidersList); + + /** The codec used by NativeApiBridge. */ + static MessageCodec getCodec() { + return NativeApiBridgeCodec.INSTANCE; + } + + /** Sets up an instance of `NativeApiBridge` to handle messages through the `binaryMessenger`. */ + static void setup(BinaryMessenger binaryMessenger, NativeApiBridge api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.NativeApiBridge.addPlugin", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + List authProvidersListArg = (List)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 wrapError(Throwable exception) { + Map 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 +@protocol FlutterBinaryMessenger; +@protocol FlutterMessageCodec; +@class FlutterError; +@class FlutterStandardTypedData; + +NS_ASSUME_NONNULL_BEGIN + + +/// The codec used by NativeApiBridge. +NSObject *NativeApiBridgeGetCodec(void); + +@protocol NativeApiBridge +- (void)addPluginAuthProvidersList:(NSArray *)authProvidersList error:(FlutterError *_Nullable *_Nonnull)error; +@end + +extern void NativeApiBridgeSetup(id binaryMessenger, NSObject *_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 + +#if !__has_feature(objc_arc) +#error File requires ARC to be enabled. +#endif + +static NSDictionary *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 *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 binaryMessenger, NSObject *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 *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) { 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 49419019b5fc4bee92aa5774a4de919291c362ea Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Mon, 27 Jun 2022 11:43:25 -0500 Subject: [PATCH 25/47] 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 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) { + override fun addPlugin( + authProvidersList: MutableList, + result: NativeApiPluginBindings.Result + ) { 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 { + 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 authProvidersList); + void addPlugin(@NonNull List authProvidersList, Result result); /** The codec used by NativeApiBridge. */ static MessageCodec 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 resultCallback = new Result() { + 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 @protocol FlutterBinaryMessenger; @@ -27,7 +27,7 @@ NS_ASSUME_NONNULL_BEGIN NSObject *NativeApiBridgeGetCodec(void); @protocol NativeApiBridge -- (void)addPluginAuthProvidersList:(NSArray *)authProvidersList error:(FlutterError *_Nullable *_Nonnull)error; +- (void)addPluginAuthProvidersList:(NSArray *)authProvidersList completion:(void(^)(FlutterError *_Nullable))completion; @end extern void NativeApiBridgeSetup(id binaryMessenger, NSObject *_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 @@ -86,13 +86,13 @@ void NativeApiBridgeSetup(id binaryMessenger, NSObject *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) { + 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 e780b226ac10a4823a026d4e251b42ecbb469694 Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Mon, 27 Jun 2022 14:28:39 -0800 Subject: [PATCH 26/47] 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 72e5923006..e34408b8e9 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 { + const EndpointConfig(this.name, this.config); + + final String name; + final AWSApiConfig config; + + @override + List 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? 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 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 _clientPool = {}; /// The registered [APIAuthProvider] instances. final Map _authProviders = {}; @@ -33,8 +45,10 @@ class AmplifyAPIDart extends AmplifyAPI { /// {@macro amplify_api.amplify_api_dart} AmplifyAPIDart({ List 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? 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 _makeCancelable(Future responseFuture) { + return CancelableOperation.fromFuture(responseFuture); + } + + CancelableOperation _prepareRestResponse( + Future 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 delete( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 get( + String path, { + Map? headers, + Map? 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 head( + String path, { + Map? headers, + Map? 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 patch( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 post( + String path, { + HttpPayload? body, + Map? headers, + Map? 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 put( + String path, { + HttpPayload? body, + Map? headers, + Map? 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? addContentTypeToHeaders( + Map? 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.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 _verifyRestOperation( + CancelableOperation 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 daaff32acc5a7eb24c8ba7f0214b101ba44d8f6e Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Wed, 13 Jul 2022 15:27:11 -0500 Subject: [PATCH 27/47] 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 { /// 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 errors; + /// A list of errors from execution. If no errors, it will be `null`. + final List? errors; const GraphQLResponse({ this.data, @@ -36,7 +36,10 @@ class GraphQLResponse { }) { 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( document: graphQLDocument, variables: {'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( 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 { /// 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? queryParameters) { + Uri getUri({String? path, Map? 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? 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> query( + {required GraphQLRequest request}) { + final graphQLClient = getGraphQLClient(apiName: request.apiName); + final uri = _getGraphQLUri(request.apiName); + + final responseFuture = sendGraphQLRequest( + request: request, client: graphQLClient, uri: uri); + return _makeCancelable>(responseFuture); + } + + @override + CancelableOperation> mutate( + {required GraphQLRequest request}) { + final graphQLClient = getGraphQLClient(apiName: request.apiName); + final uri = _getGraphQLUri(request.apiName); + + final responseFuture = sendGraphQLRequest( + request: request, client: graphQLClient, uri: uri); + return _makeCancelable>(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 decode( {required GraphQLRequest request, String? data, - required List errors}) { + List? 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> sendGraphQLRequest({ + required GraphQLRequest 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) { + 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(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 response = GraphQLResponseDecoder.instance.decode( request: request, data: result['data'] as String?, errors: errors); @@ -466,19 +466,4 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { ); } } - - List _deserializeGraphQLResponseErrors( - Map response, - ) { - final errors = response['errors'] as List?; - if (errors == null || errors.isEmpty) { - return const []; - } - return errors - .cast() - .map((message) => GraphQLResponseError.fromJson( - message.cast(), - )) - .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? 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? deserializeGraphQLResponseErrors( + Map response, +) { + final errors = response['errors'] as List?; + if (errors == null || errors.isEmpty) { + return null; + } + return errors + .cast() + .map((message) => GraphQLResponseError.fromJson( + message.cast(), + )) + .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 req = + ModelQueries.get(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()); + 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: [..._errorPath], + extensions: {..._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 d2bafc9f67907a38b4292eb6db1575ed47f6f689 Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Tue, 19 Jul 2022 08:42:49 -0800 Subject: [PATCH 28/47] 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 e34408b8e9..2687e18fe0 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 addPlugin() async {} /// Configures the plugin using the registered [config]. - Future configure({AmplifyConfig? config}) async {} + Future 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 { /// For public APIs. @JsonValue('NONE') - none, + none(AmplifyAuthProviderToken()), /// 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()), /// 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()), /// 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()), /// 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()), /// 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()); + + const APIAuthorizationType(this.authProviderToken); + final AmplifyAuthProviderToken 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 extends Token { + 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 authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }); +} + +abstract class AWSIamAmplifyAuthProvider extends AmplifyAuthProvider + implements AWSCredentialsProvider { + @override + Future authorizeRequest( + AWSBaseHttpRequest request, { + covariant IamAuthProviderOptions options, + }); +} + +abstract class TokenAmplifyAuthProvider extends AmplifyAuthProvider { + Future getLatestAuthToken(); + + @override + Future authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }) async { + final token = await getLatestAuthToken(); + request.headers.putIfAbsent(AWSHeaders.authorization, () => token); + return request; + } +} + +class AmplifyAuthProviderRepository { + final Map _authProviders = {}; + + T? getAuthProvider( + AmplifyAuthProviderToken token) { + return _authProviders[token] as T?; + } + + void registerAuthProvider( + AmplifyAuthProviderToken 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 authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }) async { + request.headers.putIfAbsent(_testAuthKey, () => 'foo'); + return request; + } +} + +class SecondTestAuthProvider extends AmplifyAuthProvider { + @override + Future authorizeRequest( + AWSBaseHttpRequest request, { + covariant AuthProviderOptions? options, + }) async { + request.headers.putIfAbsent(_testAuthKey, () => 'bar'); + return request; + } +} + +class TestAWSCredentialsAuthProvider extends AWSIamAmplifyAuthProvider { + @override + Future retrieve() async { + return const AWSCredentials( + 'fake-access-key-123', 'fake-secret-access-key-456'); + } + + @override + Future authorizeRequest( + AWSBaseHttpRequest request, { + covariant IamAuthProviderOptions? options, + }) async { + request.headers.putIfAbsent(_testAuthKey, () => 'foo'); + return request as AWSSignedRequest; + } +} + +class TestTokenProvider extends TokenAmplifyAuthProvider { + @override + Future 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(); + authRepo.registerAuthProvider(providerKey, credentialAuthProvider); + AWSIamAmplifyAuthProvider? actualAuthProvider = + authRepo.getAuthProvider(providerKey); + expect(actualAuthProvider, isA()); + }); + + 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 configure({AmplifyConfig? config}) async { + Future 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 configure({AmplifyConfig? config}) async { + Future 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 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 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 _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 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()); + }); + }); + + 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()); + expect(credentials.secretAccessKey, isA()); + }); + + 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()), + ); + }); + }); +} 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 hubEventsController; late Stream hubEvents; + final testAuthRepo = AmplifyAuthProviderRepository(); + final userDeletedEvent = isA().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(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 hubEventsController; late Stream hubEvents; + final testAuthRepo = AmplifyAuthProviderRepository(); + final emitsSignOutEvent = emitsThrough( isA().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 d6f640513532ccdabf4baf64be21135382fa8877 Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Thu, 21 Jul 2022 12:50:37 -0800 Subject: [PATCH 29/47] 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 { /// See also: /// - [API Key Authorization](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#api-key-authorization) @JsonValue('API_KEY') - apiKey(AmplifyAuthProviderToken()), + apiKey(AmplifyAuthProviderToken()), /// 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 authorizeRequest( AWSBaseHttpRequest request, { @@ -50,6 +56,14 @@ abstract class AWSIamAmplifyAuthProvider extends AmplifyAuthProvider }); } +abstract class ApiKeyAmplifyAuthProvider extends AmplifyAuthProvider { + @override + Future authorizeRequest( + AWSBaseHttpRequest request, { + covariant ApiKeyAuthProviderOptions? options, + }); +} + abstract class TokenAmplifyAuthProvider extends AmplifyAuthProvider { Future 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 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 _clientPool = {}; + final Map _clientPool = {}; /// The registered [APIAuthProvider] instances. final Map _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> query( {required GraphQLRequest request}) { - final graphQLClient = getGraphQLClient(apiName: request.apiName); + final graphQLClient = + getHttpClient(EndpointType.graphQL, apiName: request.apiName); final uri = _getGraphQLUri(request.apiName); final responseFuture = sendGraphQLRequest( @@ -171,7 +178,8 @@ class AmplifyAPIDart extends AmplifyAPI { @override CancelableOperation> mutate( {required GraphQLRequest request}) { - final graphQLClient = getGraphQLClient(apiName: request.apiName); + final graphQLClient = + getHttpClient(EndpointType.graphQL, apiName: request.apiName); final uri = _getGraphQLUri(request.apiName); final responseFuture = sendGraphQLRequest( @@ -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 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? 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 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())); + }); + + 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())); + }); + }); +} 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()); + 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); + + 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()); + }); + + 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(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 retrieve() async { + return const AWSCredentials( + 'fake-access-key-123', 'fake-secret-access-key-456'); + } + + @override + Future 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 d6d6019f1951636a2ff0950410d5b292071d4c68 Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Fri, 29 Jul 2022 09:31:47 -0500 Subject: [PATCH 30/47] 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 { /// 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? 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 { {this.apiName, required this.document, this.variables = const {}, + this.headers, this.decodePath, this.modelType}); Map serializeAsMap() => { '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> sendGraphQLRequest({ }) 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 5829de84de62d4e81908f2bd6502d060649fa4f4 Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Mon, 8 Aug 2022 08:58:01 -0800 Subject: [PATCH 31/47] 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 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())); }); - 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 retrieve() async { @@ -43,6 +45,13 @@ class TestIamAuthProvider extends AWSIamAmplifyAuthProvider { } } +class TestTokenAuthProvider extends TokenAmplifyAuthProvider { + @override + Future 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 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 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()); }); - }); - - 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()); - expect(credentials.secretAccessKey, isA()); + test('registers CognitoUserPoolsAuthProvider', () async { + final authProvider = testAuthRepo.getAuthProvider( + APIAuthorizationType.userPools.authProviderToken, + ); + expect(authProvider, isA()); }); + }); - 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()), ); }); - 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()), + throwsA(isA()), ); }); }); + + 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()); + expect(credentials.secretAccessKey, isA()); + }); + + 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()), + ); + }); + }); + + 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()), + ); + }); + }); + + 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 f6c1c1b51993b5e80dea6cf113473013a5528900 Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Mon, 22 Aug 2022 09:05:41 -0800 Subject: [PATCH 32/47] 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 4feb3e3797..4e400ff030 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 configure({AmplifyConfig? config}) async { - await super.configure(config: config); + Future 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(); 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 2e05c6f24494372cb70a621ac914d5dae43268a2 Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Fri, 26 Aug 2022 08:13:39 -0800 Subject: [PATCH 33/47] feat(api): .subscribe() for GraphQL (#1915) Co-authored-by: Elijah Quartey --- .../integration_test/graphql_tests.dart | 234 ++++++++-------- .../example/lib/graphql_api_view.dart | 20 +- .../amplify_api/lib/src/api_plugin_impl.dart | 33 +++ .../src/decorators/web_socket_auth_utils.dart | 125 +++++++++ .../src/graphql/ws/web_socket_connection.dart | 258 ++++++++++++++++++ ...web_socket_message_stream_transformer.dart | 94 +++++++ .../lib/src/graphql/ws/web_socket_types.dart | 228 ++++++++++++++++ packages/api/amplify_api/pubspec.yaml | 3 + .../amplify_api/test/dart_graphql_test.dart | 96 ++++++- packages/api/amplify_api/test/util.dart | 115 ++++++++ .../test/ws/web_socket_auth_utils_test.dart | 85 ++++++ .../test/ws/web_socket_connection_test.dart | 111 ++++++++ 12 files changed, 1275 insertions(+), 127 deletions(-) create mode 100644 packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart create mode 100644 packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart create mode 100644 packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart create mode 100644 packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart create mode 100644 packages/api/amplify_api/test/ws/web_socket_auth_utils_test.dart create mode 100644 packages/api/amplify_api/test/ws/web_socket_connection_test.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>> - _getEstablishedSubscriptionOperation( - GraphQLRequest subscriptionRequest, - void Function(GraphQLResponse) onData) async { - Completer establishedCompleter = Completer(); - final stream = - Amplify.API.subscribe(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>> + _getEstablishedSubscriptionOperation( + GraphQLRequest subscriptionRequest, + void Function(GraphQLResponse) onData) async { + Completer establishedCompleter = Completer(); + final stream = + Amplify.API.subscribe(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> _establishSubscriptionAndMutate( - GraphQLRequest subscriptionRequest, - Future Function() mutationFunction) async { - Completer> dataCompleter = Completer(); - // With stream established, exec callback with stream events. - final subscription = await _getEstablishedSubscriptionOperation( - 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> _establishSubscriptionAndMutate( + GraphQLRequest subscriptionRequest, + Future Function() mutationFunction) async { + Completer> dataCompleter = Completer(); + // With stream established, exec callback with stream events. + final subscription = await _getEstablishedSubscriptionOperation( + 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(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.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(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.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/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 { 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 query() async { 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..66e0d0ca91 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/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'; @@ -37,11 +38,16 @@ 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. final Map _clientPool = {}; + /// A map of the keys from the Amplify API config websocket connections to use + /// for that endpoint. + final Map _webSocketConnectionPool = {}; + /// The registered [APIAuthProvider] instances. final Map _authProviders = {}; @@ -123,6 +129,24 @@ 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, + logger: _logger.createChild( + 'webSocketConnection${endpoint.name}', + ), + ); + } + Uri _getGraphQLUri(String? apiName) { final endpoint = _apiConfig.getEndpoint( type: EndpointType.graphQL, @@ -187,6 +211,15 @@ class AmplifyAPIDart extends AmplifyAPI { return _makeCancelable>(responseFuture); } + @override + Stream> subscribe( + GraphQLRequest request, { + void Function()? onEstablished, + }) { + return getWebSocketConnection(apiName: request.apiName) + .subscribe(request, onEstablished); + } + // ====== REST ======= @override 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 new file mode 100644 index 0000000000..d1520c731e --- /dev/null +++ b/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart @@ -0,0 +1,125 @@ +// 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. + +@internal +library amplify_api.decorators.web_socket_auth_utils; + +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/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 _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 = '{}'; + +/// 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= +Future generateConnectionUri( + AWSApiConfig config, AmplifyAuthProviderRepository authRepo) async { + final authorizationHeaders = await _generateAuthorizationHeaders( + config, + isConnectionInit: true, + authRepo: authRepo, + body: _emptyBody, + ); + 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: { + 'header': encodedAuthHeaders, + 'payload': base64.encode(utf8.encode(_emptyBody)), + }); +} + +/// 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 +Future + 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, + isConnectionInit: false, + 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 `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> _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 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(_requiredHeaders); + 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/web_socket_connection.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart new file mode 100644 index 0000000000..939aab96b8 --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_connection.dart @@ -0,0 +1,258 @@ +// 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/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.ws.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 AmplifyLogger _logger; + + // 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. + WebSocketChannel? _channel; + StreamSubscription? _subscription; + RestartableTimer? _timeoutTimer; + + // Re-broadcasts incoming messages for child streams (single GraphQL subscriptions). + // start_ack, data, error + final StreamController _rebroadcastController = + StreamController.broadcast(); + Stream get _messageStream => _rebroadcastController.stream; + + // Manage initial connection state. + var _initMemo = AsyncMemoizer(); + Completer _connectionReady = Completer(); + + /// Fires when the connection is ready to be listened to after the first + /// `connection_ack` message. + Future get ready => _connectionReady.future; + + /// {@macro amplify_api.ws.web_socket_connection} + WebSocketConnection(this._config, this._authProviderRepo, + {required AmplifyLogger logger}) + : _logger = logger; + + /// Connects _subscription stream to _onData handler. + @visibleForTesting + StreamSubscription getStreamSubscription( + Stream stream) { + 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 + /// init message. + @visibleForTesting + Future connect(Uri connectionUri) async { + _channel = WebSocketChannel.connect( + connectionUri, + protocols: webSocketProtocols, + ); + _subscription = getStreamSubscription(_channel!.stream); + } + + void _connectionError(ApiException exception) { + _connectionReady.completeError(exception); + _channel?.sink.close(); + _resetConnectionInit(); + } + + // Reset connection init variables so it can be re-attempted. + void _resetConnectionInit() { + _initMemo = AsyncMemoizer(); + _connectionReady = Completer(); + } + + /// 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. + /// + /// Connects to WebSocket, sends connection message and resolves future once + /// connection_ack message received from server. If the connection was previously + /// established then will return previously completed future. + Future init() => _initMemo.runOnce(_init); + + Future _init() async { + final connectionUri = + await generateConnectionUri(_config, _authProviderRepo); + await connect(connectionUri); + + send(WebSocketConnectionInitMessage()); + + return ready; + } + + Future _sendSubscriptionRegistrationMessage( + GraphQLRequest 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> subscribe( + GraphQLRequest request, + void Function()? onEstablished, + ) { + // Create controller for this subscription so we can add errors. + late StreamController> controller; + controller = StreamController>.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. + _messageStream + .where((msg) => msg.id == request.id) + .transform(WebSocketSubscriptionStreamTransformer( + request, + onEstablished, + logger: _logger, + )) + .listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + cancelOnError: true, + ); + + _sendSubscriptionRegistrationMessage(request) + .catchError(controller.addError); + + return controller.stream; + } + + /// 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. + } + + /// Serializes a message as JSON string and sends over WebSocket channel. + @visibleForTesting + void send(WebSocketMessage message) { + final msgJson = json.encode(message.toJson()); + _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. + /// + /// Here, handle connection-wide messages and pass subscription events to + /// `_rebroadcastController`. + void _onData(WebSocketMessage message) { + _logger.verbose('websocket received message: ${prettyPrintJson(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(); + _logger.verbose('Connection established. Registered timer'); + return; + case MessageType.connectionError: + _connectionError(const ApiException( + 'Error occurred while connecting to the websocket')); + return; + case MessageType.keepAlive: + _timeoutTimer?.reset(); + _logger.verbose('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 other message types related to single subscriptions. + + _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 new file mode 100644 index 0000000000..e037bd1ba5 --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart @@ -0,0 +1,94 @@ +// 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. + +@internal +library amplify_api.graphql.ws.web_socket_message_stream_transformer; + +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'; + +/// Top-level transformer. +class WebSocketMessageStreamTransformer + extends StreamTransformerBase { + /// Transforms raw web socket response (String) to `WebSocketMessage` for all input + /// from channel. + const WebSocketMessageStreamTransformer(); + + @override + Stream bind(Stream stream) { + return stream.cast().map>((str) { + return json.decode(str) as Map; + }).map(WebSocketMessage.fromJson); + } +} + +/// Final level of transformation for converting `WebSocketMessage`s to stream +/// of `GraphQLResponse` that is eventually passed to public API `Amplify.API.subscribe`. +class WebSocketSubscriptionStreamTransformer + extends StreamTransformerBase> { + /// request for this stream, needed to properly decode response events + final GraphQLRequest 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, { + required this.logger, + }); + + @override + Stream> bind(Stream 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; + // TODO(ragingsquirrel3): refactor decoder + final errors = deserializeGraphQLResponseErrors(payload.toJson()); + yield GraphQLResponseDecoder.instance.decode( + 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; + 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 new file mode 100644 index 0000000000..c957b82641 --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_types.dart @@ -0,0 +1,228 @@ +// 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 + +@internal +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'; + +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) { + return MessageType.values.firstWhere((el) => json == el.type); + } +} + +@immutable +abstract class WebSocketMessagePayload { + const WebSocketMessagePayload(); + + static const 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 toJson(); + + @override + String toString() => prettyPrintJson(toJson()); +} + +@internal +class ConnectionAckMessagePayload extends WebSocketMessagePayload { + final int connectionTimeoutMs; + + const ConnectionAckMessagePayload(this.connectionTimeoutMs); + + static ConnectionAckMessagePayload fromJson(Map json) { + final connectionTimeoutMs = json['connectionTimeoutMs'] as int; + return ConnectionAckMessagePayload(connectionTimeoutMs); + } + + @override + Map toJson() => { + 'connectionTimeoutMs': connectionTimeoutMs, + }; +} + +class SubscriptionRegistrationPayload extends WebSocketMessagePayload { + final GraphQLRequest request; + final AWSApiConfig config; + final Map authorizationHeaders; + + const SubscriptionRegistrationPayload({ + required this.request, + required this.config, + required this.authorizationHeaders, + }); + + @override + Map toJson() { + return { + 'data': jsonEncode( + {'variables': request.variables, 'query': request.document}), + 'extensions': >{ + 'authorization': authorizationHeaders + } + }; + } +} + +class SubscriptionDataPayload extends WebSocketMessagePayload { + final Map? data; + final Map? errors; + + const 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(), + errors?.cast(), + ); + } + + @override + Map toJson() => { + 'data': data, + 'errors': errors, + }; +} + +class WebSocketError extends WebSocketMessagePayload implements Exception { + final List errors; + + const WebSocketError(this.errors); + + static WebSocketError fromJson(Map json) { + final errors = json['errors'] as List?; + return WebSocketError(errors?.cast() ?? []); + } + + @override + Map toJson() => { + 'errors': errors, + }; +} + +@immutable +class WebSocketMessage { + final String? id; + final MessageType messageType; + final WebSocketMessagePayload? payload; + + WebSocketMessage({ + String? id, + required this.messageType, + this.payload, + }) : id = id ?? uuid(); + + const 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 toJson() => { + if (id != null) 'id': id, + 'type': messageType.type, + if (payload != null) 'payload': payload?.toJson(), + }; + + @override + String toString() { + return prettyPrintJson(this); + } +} + +class WebSocketConnectionInitMessage extends WebSocketMessage { + WebSocketConnectionInitMessage() + : super(messageType: MessageType.connectionInit); +} + +class WebSocketSubscriptionRegistrationMessage extends WebSocketMessage { + WebSocketSubscriptionRegistrationMessage({ + required String id, + required SubscriptionRegistrationPayload payload, + }) : super(messageType: MessageType.start, payload: payload, id: id); +} + +class WebSocketStopMessage extends WebSocketMessage { + WebSocketStopMessage({required String id}) + : super(messageType: MessageType.stop, id: id); +} diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index a4b2121efe..aa5240a437 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -21,8 +21,11 @@ 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 + dev_dependencies: amplify_lints: diff --git a/packages/api/amplify_api/test/dart_graphql_test.dart b/packages/api/amplify_api/test/dart_graphql_test.dart index 4d9d8ec47f..b37a2611f3 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,34 @@ void main() { } } } '''; - final req = GraphQLRequest(document: graphQLDocument, variables: {}); + 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('Query returns proper response.data with dynamic type', () 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; @@ -147,8 +181,10 @@ void main() { } } '''; final graphQLVariables = {'name': 'Test Blog 1'}; - final req = GraphQLRequest( - document: graphQLDocument, variables: graphQLVariables); + final req = GraphQLRequest( + document: graphQLDocument, + variables: graphQLVariables, + ); final operation = Amplify.API.mutate(request: req); final res = await operation.value; @@ -158,6 +194,33 @@ void main() { expect(res.data, equals(expected)); expect(res.errors, equals(null)); }); + + test('subscribe() should return a subscription stream', () async { + Completer establishedCompleter = Completer(); + Completer dataCompleter = Completer(); + const graphQLDocument = '''subscription MySubscription { + onCreateBlog { + id + name + createdAt + } + }'''; + final subscriptionRequest = + GraphQLRequest(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 +247,33 @@ void main() { expect(res.data?.id, _modelQueryId); expect(res.errors, equals(null)); }); + + test('subscribe() should decode model data', () async { + Completer establishedCompleter = Completer(); + final subscriptionRequest = ModelSubscriptions.onCreate(Post.classType); + final subscription = Amplify.API.subscribe( + subscriptionRequest, + onEstablished: () => establishedCompleter.complete(), + ); + await establishedCompleter.future; + + late StreamSubscription> streamSub; + streamSub = subscription.listen( + expectAsync1((event) { + expect(event.data, isA()); + streamSub.cancel(); + }), + ); + }); }); group('Error Handling', () { test('response errors are decoded', () async { String graphQLDocument = ''' TestError '''; - final req = GraphQLRequest(document: graphQLDocument, variables: {}); + final req = GraphQLRequest( + document: graphQLDocument, + variables: {}, + ); final operation = Amplify.API.query(request: req); final res = await operation.value; @@ -209,7 +293,7 @@ void main() { }); test('canceled query request should never resolve', () async { - final req = GraphQLRequest(document: '', variables: {}); + final req = GraphQLRequest(document: '', variables: {}); final operation = Amplify.API.query(request: req); operation.cancel(); operation.then((p0) => fail('Request should have been cancelled.')); @@ -218,7 +302,7 @@ void main() { }); test('canceled mutation request should never resolve', () async { - final req = GraphQLRequest(document: '', variables: {}); + final req = GraphQLRequest(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 sentMessages = []; + + MockWebSocketConnection( + AWSApiConfig config, AmplifyAuthProviderRepository authProviderRepo) + : super(config, authProviderRepo, logger: AmplifyLogger()); + + WebSocketMessage? get lastSentMessage => sentMessages.lastOrNull; + + final messageStream = StreamController(); + + @override + Future 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 = []; + 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(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 new file mode 100644 index 0000000000..9a1e3e6545 --- /dev/null +++ b/packages/api/amplify_api/test/ws/web_socket_connection_test.dart @@ -0,0 +1,111 @@ +// 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/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(); + + late MockWebSocketConnection connection; + + const graphQLDocument = '''subscription MySubscription { + onCreateBlog { + id + name + createdAt + } + }'''; + final subscriptionRequest = GraphQLRequest(document: graphQLDocument); + + setUp(() { + connection = MockWebSocketConnection( + testApiKeyConfig, + getTestAuthProviderRepo(), + ); + }); + + 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); + expect( + connection.connectedUri.toString(), + expectedApiKeyWebSocketConnectionUrl, + ); + expect( + connection.lastSentMessage?.messageType, MessageType.connectionInit); + }); + + test('subscribe() should initialize the connection and call onEstablished', + () async { + connection.subscribe(subscriptionRequest, expectAsync0(() {})); + expectLater(connection.ready, completes); + }); + + test( + 'subscribe() should send SubscriptionRegistrationMessage with authorized payload correctly serialized', + () async { + connection.init(); + await connection.ready; + Completer establishedCompleter = Completer(); + connection.subscribe(subscriptionRequest, () { + establishedCompleter.complete(); + }); + await establishedCompleter.future; + + final lastMessage = connection.lastSentMessage; + expect(lastMessage?.messageType, MessageType.start); + final payloadJson = lastMessage?.payload?.toJson(); + final apiKeyFromPayload = + payloadJson?['extensions']['authorization'][xApiKey]; + expect(apiKeyFromPayload, testApiKeyConfig.apiKey); + }); + + test('subscribe() should return a subscription stream', () async { + final subscription = connection.subscribe( + subscriptionRequest, + null, + ); + + late StreamSubscription> streamSub; + streamSub = subscription.listen( + expectAsync1((event) { + expect(event.data, json.encode(mockSubscriptionData)); + streamSub.cancel(); + }), + ); + }); + + test('cancel() should send a stop message', () async { + Completer 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); + }); + }); +} From 3cae1e90c6034a0971a5ca9ab3be488967b9d838 Mon Sep 17 00:00:00 2001 From: equartey Date: Fri, 9 Sep 2022 13:57:46 -0500 Subject: [PATCH 34/47] fix: unit & integ tests passing --- .../src/graphql/ws/web_socket_connection.dart | 39 +++++++------------ 1 file changed, 15 insertions(+), 24 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 1aba45464b..02614c79bb 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 @@ -40,7 +40,7 @@ const Duration _defaultRetryTimeout = Duration(seconds: 5); class WebSocketConnection implements Closeable { /// Allowed protocols for this connection. static const webSocketProtocols = ['graphql-ws']; - final AmplifyLogger _logger; + late final AmplifyLogger _logger; // Config and auth repo together determine how to authorize connection URLs // and subscription registration messages. @@ -57,9 +57,9 @@ class WebSocketConnection implements Closeable { StreamController.broadcast(); RestartableTimer? _timeoutTimer; - http.Client? _pingClient; + final http.Client _pingClient = http.Client(); Timer? _pingTimer; - late Uri? _pingUri; + late Uri _pingUri; bool _hasNetwork = false; bool _hasConnectionBroken = false; final Set> _subscriptionRequests = HashSet( @@ -78,7 +78,6 @@ class WebSocketConnection implements Closeable { // Manage initial connection state. var _initMemo = AsyncMemoizer(); - var _hubMemo = AsyncMemoizer(); Completer _connectionReady = Completer(); /// Fires when the connection is ready to be listened to after the first @@ -88,8 +87,13 @@ class WebSocketConnection implements Closeable { /// {@macro amplify_api.ws.web_socket_connection} WebSocketConnection( this._config, this._authProviderRepo, this._subscriptionOptions, - {required AmplifyLogger logger}) - : _logger = logger; + {required AmplifyLogger logger}) { + _logger = logger; + _pingUri = Uri.parse(_config.endpoint).replace(path: 'ping'); + + // Establish HubEvents Stream + Amplify.Hub.addChannel(HubChannel.Api, _hubEventsController.stream); + } /// Connects _subscription stream to _onData handler. @visibleForTesting @@ -112,7 +116,7 @@ class WebSocketConnection implements Closeable { if (_hasNetwork && _subscriptionRequests.isNotEmpty) { _retry(); } else { - _hubEventsController?.add(SubscriptionHubEvent.failed()); + _hubEventsController.add(SubscriptionHubEvent.failed()); } }, // Todo(equartey): investigate why this breaks onDone & onError when an error occurs. @@ -176,13 +180,12 @@ class WebSocketConnection implements Closeable { void close([int closeStatus = _defaultCloseStatus]) { _logger.verbose('Closing web socket connection.'); _hubEventsController.add(SubscriptionHubEvent.disconnected()); - _rebroadcastController.close(); _resetConnectionInit(); _resetConnectionVariables(closeStatus); _subscriptionRequests.clear(); - _hubEventsController.close(); + _pingClient.close(); } /// Initiate polling @@ -198,9 +201,8 @@ class WebSocketConnection implements Closeable { /// reconnection through retry/back off. Future _poll() async { try { - if (_pingClient == null) return; final res = await _getPollRequest(); - if (res!.body != 'healthy') { + if (res.body != 'healthy') { throw ApiException( 'Subscription connection broken. AppSync status check returned: ${res.body}', recoverySuggestion: @@ -229,8 +231,6 @@ class WebSocketConnection implements Closeable { /// Attempts to connect to the configured graphql endpoint. /// Upon successful ping, a new websocket connection is initialized. Future _retry() async { - if (_pingClient == null) return; - RetryOptions retryStrategy = _subscriptionOptions?.retryOptions ?? const RetryOptions(); @@ -240,7 +240,7 @@ class WebSocketConnection implements Closeable { await retryStrategy.retry( // Make a GET request - () => _getPollRequest()!.timeout(_defaultRetryTimeout)); + () => _getPollRequest().timeout(_defaultRetryTimeout)); // we can reach AppSync, precede with reconnect _init(); @@ -259,8 +259,7 @@ class WebSocketConnection implements Closeable { /// [GET] request on the configured AppSync url via the `/ping` endpoint Future _getPollRequest() { - _pingUri ??= Uri.parse(_config.endpoint).replace(path: 'ping'); - return _pingClient!.get(_pingUri!); + return _pingClient.get(_pingUri); } /// Clear variables used to track subscriptions and other helper variables @@ -271,13 +270,11 @@ class WebSocketConnection implements Closeable { _channel?.sink.done.whenComplete(() { _network?.cancel(); - _pingClient?.close(); _subscription?.cancel(); _timeoutTimer?.cancel(); _channel = null; _network = null; - _pingClient = null; _subscription = null; _timeoutTimer = null; }); @@ -296,10 +293,6 @@ class WebSocketConnection implements Closeable { _connectionReady = Completer(); _resetConnectionVariables(); - // establish hub events controller - _hubMemo.runOnce(() => - Amplify.Hub.addChannel(HubChannel.Api, _hubEventsController.stream)); - _hubEventsController.add(SubscriptionHubEvent.connecting()); final connectionUri = @@ -310,8 +303,6 @@ class WebSocketConnection implements Closeable { await ready; - _pingClient = http.Client(); - // connection ready, start polling _startPolling(); From ea0950e8f04d92f774cc40064e1144e21610b346 Mon Sep 17 00:00:00 2001 From: equartey Date: Fri, 9 Sep 2022 14:15:05 -0500 Subject: [PATCH 35/47] fix: removed dup var --- .../amplify_api/lib/src/graphql/ws/web_socket_connection.dart | 1 - 1 file changed, 1 deletion(-) 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 3119bf043c..7b79f85e71 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 @@ -42,7 +42,6 @@ class WebSocketConnection implements Closeable { /// Allowed protocols for this connection. static const webSocketProtocols = ['graphql-ws']; late final AmplifyLogger _logger; - final AmplifyLogger _logger; // Config and auth repo together determine how to authorize connection URLs // and subscription registration messages. From 76d9907a84accd45063dc5253d9aaa37c974841c Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Thu, 1 Sep 2022 16:51:53 -0500 Subject: [PATCH 36/47] chore(api): Api Example - generate files for multi-plaform (#2080) --- packages/api/amplify_api/example/.metadata | 39 +- .../res/drawable-v21/launch_background.xml | 12 + .../app/src/main/res/values-night/styles.xml | 18 + .../api/amplify_api/example/linux/.gitignore | 1 + .../amplify_api/example/linux/CMakeLists.txt | 138 +++++ .../example/linux/flutter/CMakeLists.txt | 88 +++ .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 + .../linux/flutter/generated_plugins.cmake | 23 + .../api/amplify_api/example/linux/main.cc | 6 + .../example/linux/my_application.cc | 104 ++++ .../example/linux/my_application.h | 18 + .../api/amplify_api/example/macos/.gitignore | 7 + .../macos/Flutter/Flutter-Debug.xcconfig | 2 + .../macos/Flutter/Flutter-Release.xcconfig | 2 + .../Flutter/GeneratedPluginRegistrant.swift | 10 + .../api/amplify_api/example/macos/Podfile | 40 ++ .../macos/Runner.xcodeproj/project.pbxproj | 572 ++++++++++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 87 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../example/macos/Runner/AppDelegate.swift | 9 + .../AppIcon.appiconset/Contents.json | 68 +++ .../AppIcon.appiconset/app_icon_1024.png | Bin 0 -> 46993 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 0 -> 3276 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 0 -> 1429 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 0 -> 5933 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 0 -> 1243 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 0 -> 14800 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 0 -> 1874 bytes .../macos/Runner/Base.lproj/MainMenu.xib | 343 +++++++++++ .../macos/Runner/Configs/AppInfo.xcconfig | 14 + .../macos/Runner/Configs/Debug.xcconfig | 2 + .../macos/Runner/Configs/Release.xcconfig | 2 + .../macos/Runner/Configs/Warnings.xcconfig | 13 + .../macos/Runner/DebugProfile.entitlements | 12 + .../example/macos/Runner/Info.plist | 32 + .../macos/Runner/MainFlutterWindow.swift | 15 + .../example/macos/Runner/Release.entitlements | 8 + .../api/amplify_api/example/web/favicon.png | Bin 0 -> 917 bytes .../example/web/icons/Icon-192.png | Bin 0 -> 5292 bytes .../example/web/icons/Icon-512.png | Bin 0 -> 8252 bytes .../example/web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes .../example/web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes .../api/amplify_api/example/web/index.html | 58 ++ .../api/amplify_api/example/web/manifest.json | 35 ++ .../amplify_api/example/windows/.gitignore | 17 + .../example/windows/CMakeLists.txt | 101 ++++ .../example/windows/flutter/CMakeLists.txt | 104 ++++ .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 + .../windows/flutter/generated_plugins.cmake | 23 + .../example/windows/runner/CMakeLists.txt | 32 + .../example/windows/runner/Runner.rc | 121 ++++ .../example/windows/runner/flutter_window.cpp | 61 ++ .../example/windows/runner/flutter_window.h | 33 + .../example/windows/runner/main.cpp | 43 ++ .../example/windows/runner/resource.h | 16 + .../windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes .../windows/runner/runner.exe.manifest | 20 + .../example/windows/runner/utils.cpp | 64 ++ .../example/windows/runner/utils.h | 19 + .../example/windows/runner/win32_window.cpp | 245 ++++++++ .../example/windows/runner/win32_window.h | 98 +++ 65 files changed, 2848 insertions(+), 2 deletions(-) create mode 100644 packages/api/amplify_api/example/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 packages/api/amplify_api/example/android/app/src/main/res/values-night/styles.xml create mode 100644 packages/api/amplify_api/example/linux/.gitignore create mode 100644 packages/api/amplify_api/example/linux/CMakeLists.txt create mode 100644 packages/api/amplify_api/example/linux/flutter/CMakeLists.txt create mode 100644 packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.cc create mode 100644 packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.h create mode 100644 packages/api/amplify_api/example/linux/flutter/generated_plugins.cmake create mode 100644 packages/api/amplify_api/example/linux/main.cc create mode 100644 packages/api/amplify_api/example/linux/my_application.cc create mode 100644 packages/api/amplify_api/example/linux/my_application.h create mode 100644 packages/api/amplify_api/example/macos/.gitignore create mode 100644 packages/api/amplify_api/example/macos/Flutter/Flutter-Debug.xcconfig create mode 100644 packages/api/amplify_api/example/macos/Flutter/Flutter-Release.xcconfig create mode 100644 packages/api/amplify_api/example/macos/Flutter/GeneratedPluginRegistrant.swift create mode 100644 packages/api/amplify_api/example/macos/Podfile create mode 100644 packages/api/amplify_api/example/macos/Runner.xcodeproj/project.pbxproj create mode 100644 packages/api/amplify_api/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/api/amplify_api/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 packages/api/amplify_api/example/macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 packages/api/amplify_api/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/api/amplify_api/example/macos/Runner/AppDelegate.swift create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png create mode 100644 packages/api/amplify_api/example/macos/Runner/Base.lproj/MainMenu.xib create mode 100644 packages/api/amplify_api/example/macos/Runner/Configs/AppInfo.xcconfig create mode 100644 packages/api/amplify_api/example/macos/Runner/Configs/Debug.xcconfig create mode 100644 packages/api/amplify_api/example/macos/Runner/Configs/Release.xcconfig create mode 100644 packages/api/amplify_api/example/macos/Runner/Configs/Warnings.xcconfig create mode 100644 packages/api/amplify_api/example/macos/Runner/DebugProfile.entitlements create mode 100644 packages/api/amplify_api/example/macos/Runner/Info.plist create mode 100644 packages/api/amplify_api/example/macos/Runner/MainFlutterWindow.swift create mode 100644 packages/api/amplify_api/example/macos/Runner/Release.entitlements create mode 100644 packages/api/amplify_api/example/web/favicon.png create mode 100644 packages/api/amplify_api/example/web/icons/Icon-192.png create mode 100644 packages/api/amplify_api/example/web/icons/Icon-512.png create mode 100644 packages/api/amplify_api/example/web/icons/Icon-maskable-192.png create mode 100644 packages/api/amplify_api/example/web/icons/Icon-maskable-512.png create mode 100644 packages/api/amplify_api/example/web/index.html create mode 100644 packages/api/amplify_api/example/web/manifest.json create mode 100644 packages/api/amplify_api/example/windows/.gitignore create mode 100644 packages/api/amplify_api/example/windows/CMakeLists.txt create mode 100644 packages/api/amplify_api/example/windows/flutter/CMakeLists.txt create mode 100644 packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.cc create mode 100644 packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.h create mode 100644 packages/api/amplify_api/example/windows/flutter/generated_plugins.cmake create mode 100644 packages/api/amplify_api/example/windows/runner/CMakeLists.txt create mode 100644 packages/api/amplify_api/example/windows/runner/Runner.rc create mode 100644 packages/api/amplify_api/example/windows/runner/flutter_window.cpp create mode 100644 packages/api/amplify_api/example/windows/runner/flutter_window.h create mode 100644 packages/api/amplify_api/example/windows/runner/main.cpp create mode 100644 packages/api/amplify_api/example/windows/runner/resource.h create mode 100644 packages/api/amplify_api/example/windows/runner/resources/app_icon.ico create mode 100644 packages/api/amplify_api/example/windows/runner/runner.exe.manifest create mode 100644 packages/api/amplify_api/example/windows/runner/utils.cpp create mode 100644 packages/api/amplify_api/example/windows/runner/utils.h create mode 100644 packages/api/amplify_api/example/windows/runner/win32_window.cpp create mode 100644 packages/api/amplify_api/example/windows/runner/win32_window.h diff --git a/packages/api/amplify_api/example/.metadata b/packages/api/amplify_api/example/.metadata index bcef94eb78..95f73f55c1 100644 --- a/packages/api/amplify_api/example/.metadata +++ b/packages/api/amplify_api/example/.metadata @@ -1,10 +1,45 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled and should not be manually edited. +# This file should be version controlled. version: - revision: bbfbf1770cca2da7c82e887e4e4af910034800b6 + revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 channel: stable project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + - platform: android + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + - platform: ios + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + - platform: linux + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + - platform: macos + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + - platform: web + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + - platform: windows + create_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + base_revision: fb57da5f945d02ef4f98dfd9409a72b7cce74268 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/api/amplify_api/example/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/api/amplify_api/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000000..f74085f3f6 --- /dev/null +++ b/packages/api/amplify_api/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/api/amplify_api/example/android/app/src/main/res/values-night/styles.xml b/packages/api/amplify_api/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..06952be745 --- /dev/null +++ b/packages/api/amplify_api/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/api/amplify_api/example/linux/.gitignore b/packages/api/amplify_api/example/linux/.gitignore new file mode 100644 index 0000000000..d3896c9844 --- /dev/null +++ b/packages/api/amplify_api/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/packages/api/amplify_api/example/linux/CMakeLists.txt b/packages/api/amplify_api/example/linux/CMakeLists.txt new file mode 100644 index 0000000000..7070609222 --- /dev/null +++ b/packages/api/amplify_api/example/linux/CMakeLists.txt @@ -0,0 +1,138 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.amazonaws.amplify.example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/packages/api/amplify_api/example/linux/flutter/CMakeLists.txt b/packages/api/amplify_api/example/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000000..d5bd01648a --- /dev/null +++ b/packages/api/amplify_api/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.cc b/packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..e71a16d23d --- /dev/null +++ b/packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.h b/packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..e0f0a47bc0 --- /dev/null +++ b/packages/api/amplify_api/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/api/amplify_api/example/linux/flutter/generated_plugins.cmake b/packages/api/amplify_api/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..2e1de87a7e --- /dev/null +++ b/packages/api/amplify_api/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/api/amplify_api/example/linux/main.cc b/packages/api/amplify_api/example/linux/main.cc new file mode 100644 index 0000000000..e7c5c54370 --- /dev/null +++ b/packages/api/amplify_api/example/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/packages/api/amplify_api/example/linux/my_application.cc b/packages/api/amplify_api/example/linux/my_application.cc new file mode 100644 index 0000000000..0ba8f43096 --- /dev/null +++ b/packages/api/amplify_api/example/linux/my_application.cc @@ -0,0 +1,104 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "example"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/packages/api/amplify_api/example/linux/my_application.h b/packages/api/amplify_api/example/linux/my_application.h new file mode 100644 index 0000000000..72271d5e41 --- /dev/null +++ b/packages/api/amplify_api/example/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/packages/api/amplify_api/example/macos/.gitignore b/packages/api/amplify_api/example/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/packages/api/amplify_api/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/packages/api/amplify_api/example/macos/Flutter/Flutter-Debug.xcconfig b/packages/api/amplify_api/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..4b81f9b2d2 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/api/amplify_api/example/macos/Flutter/Flutter-Release.xcconfig b/packages/api/amplify_api/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..5caa9d1579 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/api/amplify_api/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/api/amplify_api/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000000..cccf817a52 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,10 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { +} diff --git a/packages/api/amplify_api/example/macos/Podfile b/packages/api/amplify_api/example/macos/Podfile new file mode 100644 index 0000000000..dade8dfad0 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/api/amplify_api/example/macos/Runner.xcodeproj/project.pbxproj b/packages/api/amplify_api/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..c84862c675 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,572 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 51; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example.app */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1300; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/packages/api/amplify_api/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/api/amplify_api/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/api/amplify_api/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/api/amplify_api/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..fb7259e177 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/api/amplify_api/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/api/amplify_api/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..1d526a16ed --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/api/amplify_api/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/api/amplify_api/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/api/amplify_api/example/macos/Runner/AppDelegate.swift b/packages/api/amplify_api/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..d53ef64377 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..3c4935a7ca84f0976aca34b7f2895d65fb94d1ea GIT binary patch literal 46993 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 literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000000000000000000000000000000000..ed4cc16421680a50164ba74381b4b35ceaa0ccfc GIT binary patch literal 3276 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 N;OXk;vd$@?2>?>Ex^Vyi literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000000000000000000000000000000000..bcbf36df2f2aaaa0a63c7dabc94e600184229d0d GIT binary patch literal 5933 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 f$KZHO*s&vt|9^w-6P?|#0pRK8NSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fg!ItFh?!xdN1Q+aGJ{c&& zS>O>_%)r1c48n{Iv*t(u1=&kHeO=ifbFy+6aSK)V_AxLppYn8Z42d|rc6w}vOsL55 z`t&mC&y2@JTEyg!eDiFX^k#CC!jq%>erB=yHqUP0XcDOTw6ko}L zX;EmMrq(fKk*eygEuA616;0)>@A{TK|55PV@70 z$OfzS*(VJxQev3J?yY?O=ul(v`fp}?u9z`JK3ugibK>)DyCwImZOF4d{xK%%Ks1*} zv$oa)9anR%lXIBUqYnhLmT>VOzHfNP?ZwJNZ!5$s9M08RynIvaXw>@G^T9@r9^KH1 zVy??F&uuk)bH9Y4pQY!hP58i_H6 znl-NcuCpLV6ZWU;4C zu@9exF&OZi`Bovq_m%T+WhU2kvkz@^_LpycBvqm3bMpLw8X-Or5sL>0AKE1$(k_L=_Zc=CUq#=x1-QZf)G7nHu@fmsQ1eN_N3+nTEz`4HI4Z6uVlE zJH+X&det8JU?tO?upcM4Z=cV!JV;yF>FfL5Q$M|W_2Z!P`S=}Wzp|_1^#d%e?_H`> zV@%vA$+bFVqhw9`U;TfP|5|PD{||OiYdor8P*i??|NJcb%kzT_73*7WE?Ua5hAnR2 z=7WE=PhTlJ#ZeRznjTUb;`E(wkMZrj4e|Hilz-mK>9cZHQY**5TUPw~u}k;u73KI}xAx!0m-)GVia|x^d3p~s_9gh83jA&Ra<8rM%`>U3x69t&NzbwWY}7Ar?)FK#IZ0z|d0H0EkRO w3{9;}4Xg|ebq&m|3=9_N6z8I7$jwj5OsmAL;bP(Gi$Dzwp00i_>zopr02+f8CIA2c literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/api/amplify_api/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000000000000000000000000000000000000..e71a726136a47ed24125c7efc79d68a4a01961b4 GIT binary patch literal 14800 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/api/amplify_api/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/api/amplify_api/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..351ec5d179 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.amazonaws.amplify.example + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2022 com.amazonaws.amplify. All rights reserved. diff --git a/packages/api/amplify_api/example/macos/Runner/Configs/Debug.xcconfig b/packages/api/amplify_api/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/api/amplify_api/example/macos/Runner/Configs/Release.xcconfig b/packages/api/amplify_api/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/api/amplify_api/example/macos/Runner/Configs/Warnings.xcconfig b/packages/api/amplify_api/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/packages/api/amplify_api/example/macos/Runner/DebugProfile.entitlements b/packages/api/amplify_api/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..dddb8a30c8 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/packages/api/amplify_api/example/macos/Runner/Info.plist b/packages/api/amplify_api/example/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/packages/api/amplify_api/example/macos/Runner/MainFlutterWindow.swift b/packages/api/amplify_api/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..2722837ec9 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController.init() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/packages/api/amplify_api/example/macos/Runner/Release.entitlements b/packages/api/amplify_api/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..852fa1a472 --- /dev/null +++ b/packages/api/amplify_api/example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/packages/api/amplify_api/example/web/favicon.png b/packages/api/amplify_api/example/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/web/icons/Icon-192.png b/packages/api/amplify_api/example/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 zcmZ`-2T+sGz6~)*FVZ`aW+(v>MIm&M-g^@e2u-B-DoB?qO+b1Tq<5uCCv>ESfRum& zp%X;f!~1{tzL__3=gjVJ=j=J>+nMj%ncXj1Q(b|Ckbw{Y0FWpt%4y%$uD=Z*c-x~o zE;IoE;xa#7Ll5nj-e4CuXB&G*IM~D21rCP$*xLXAK8rIMCSHuSu%bL&S3)8YI~vyp@KBu9Ph7R_pvKQ@xv>NQ`dZp(u{Z8K3yOB zn7-AR+d2JkW)KiGx0hosml;+eCXp6+w%@STjFY*CJ?udJ64&{BCbuebcuH;}(($@@ znNlgBA@ZXB)mcl9nbX#F!f_5Z=W>0kh|UVWnf!At4V*LQP%*gPdCXd6P@J4Td;!Ur z<2ZLmwr(NG`u#gDEMP19UcSzRTL@HsK+PnIXbVBT@oHm53DZr?~V(0{rsalAfwgo zEh=GviaqkF;}F_5-yA!1u3!gxaR&Mj)hLuj5Q-N-@Lra{%<4ONja8pycD90&>yMB` zchhd>0CsH`^|&TstH-8+R`CfoWqmTTF_0?zDOY`E`b)cVi!$4xA@oO;SyOjJyP^_j zx^@Gdf+w|FW@DMdOi8=4+LJl$#@R&&=UM`)G!y%6ZzQLoSL%*KE8IO0~&5XYR9 z&N)?goEiWA(YoRfT{06&D6Yuu@Qt&XVbuW@COb;>SP9~aRc+z`m`80pB2o%`#{xD@ zI3RAlukL5L>px6b?QW1Ac_0>ew%NM!XB2(H+1Y3AJC?C?O`GGs`331Nd4ZvG~bMo{lh~GeL zSL|tT*fF-HXxXYtfu5z+T5Mx9OdP7J4g%@oeC2FaWO1D{=NvL|DNZ}GO?O3`+H*SI z=grGv=7dL{+oY0eJFGO!Qe(e2F?CHW(i!!XkGo2tUvsQ)I9ev`H&=;`N%Z{L zO?vV%rDv$y(@1Yj@xfr7Kzr<~0{^T8wM80xf7IGQF_S-2c0)0D6b0~yD7BsCy+(zL z#N~%&e4iAwi4F$&dI7x6cE|B{f@lY5epaDh=2-(4N05VO~A zQT3hanGy_&p+7Fb^I#ewGsjyCEUmSCaP6JDB*=_()FgQ(-pZ28-{qx~2foO4%pM9e z*_63RT8XjgiaWY|*xydf;8MKLd{HnfZ2kM%iq}fstImB-K6A79B~YoPVa@tYN@T_$ zea+9)<%?=Fl!kd(Y!G(-o}ko28hg2!MR-o5BEa_72uj7Mrc&{lRh3u2%Y=Xk9^-qa zBPWaD=2qcuJ&@Tf6ue&)4_V*45=zWk@Z}Q?f5)*z)-+E|-yC4fs5CE6L_PH3=zI8p z*Z3!it{1e5_^(sF*v=0{`U9C741&lub89gdhKp|Y8CeC{_{wYK-LSbp{h)b~9^j!s z7e?Y{Z3pZv0J)(VL=g>l;<}xk=T*O5YR|hg0eg4u98f2IrA-MY+StQIuK-(*J6TRR z|IM(%uI~?`wsfyO6Tgmsy1b3a)j6M&-jgUjVg+mP*oTKdHg?5E`!r`7AE_#?Fc)&a z08KCq>Gc=ne{PCbRvs6gVW|tKdcE1#7C4e`M|j$C5EYZ~Y=jUtc zj`+?p4ba3uy7><7wIokM79jPza``{Lx0)zGWg;FW1^NKY+GpEi=rHJ+fVRGfXO zPHV52k?jxei_!YYAw1HIz}y8ZMwdZqU%ESwMn7~t zdI5%B;U7RF=jzRz^NuY9nM)&<%M>x>0(e$GpU9th%rHiZsIT>_qp%V~ILlyt^V`=d z!1+DX@ah?RnB$X!0xpTA0}lN@9V-ePx>wQ?-xrJr^qDlw?#O(RsXeAvM%}rg0NT#t z!CsT;-vB=B87ShG`GwO;OEbeL;a}LIu=&@9cb~Rsx(ZPNQ!NT7H{@j0e(DiLea>QD zPmpe90gEKHEZ8oQ@6%E7k-Ptn#z)b9NbD@_GTxEhbS+}Bb74WUaRy{w;E|MgDAvHw zL)ycgM7mB?XVh^OzbC?LKFMotw3r@i&VdUV%^Efdib)3@soX%vWCbnOyt@Y4swW925@bt45y0HY3YI~BnnzZYrinFy;L?2D3BAL`UQ zEj))+f>H7~g8*VuWQ83EtGcx`hun$QvuurSMg3l4IP8Fe`#C|N6mbYJ=n;+}EQm;< z!!N=5j1aAr_uEnnzrEV%_E|JpTb#1p1*}5!Ce!R@d$EtMR~%9# zd;h8=QGT)KMW2IKu_fA_>p_und#-;Q)p%%l0XZOXQicfX8M~7?8}@U^ihu;mizj)t zgV7wk%n-UOb z#!P5q?Ex+*Kx@*p`o$q8FWL*E^$&1*!gpv?Za$YO~{BHeGY*5%4HXUKa_A~~^d z=E*gf6&+LFF^`j4$T~dR)%{I)T?>@Ma?D!gi9I^HqvjPc3-v~=qpX1Mne@*rzT&Xw zQ9DXsSV@PqpEJO-g4A&L{F&;K6W60D!_vs?Vx!?w27XbEuJJP&);)^+VF1nHqHBWu z^>kI$M9yfOY8~|hZ9WB!q-9u&mKhEcRjlf2nm_@s;0D#c|@ED7NZE% zzR;>P5B{o4fzlfsn3CkBK&`OSb-YNrqx@N#4CK!>bQ(V(D#9|l!e9(%sz~PYk@8zt zPN9oK78&-IL_F zhsk1$6p;GqFbtB^ZHHP+cjMvA0(LqlskbdYE_rda>gvQLTiqOQ1~*7lg%z*&p`Ry& zRcG^DbbPj_jOKHTr8uk^15Boj6>hA2S-QY(W-6!FIq8h$<>MI>PYYRenQDBamO#Fv zAH5&ImqKBDn0v5kb|8i0wFhUBJTpT!rB-`zK)^SNnRmLraZcPYK7b{I@+}wXVdW-{Ps17qdRA3JatEd?rPV z4@}(DAMf5EqXCr4-B+~H1P#;t@O}B)tIJ(W6$LrK&0plTmnPpb1TKn3?f?Kk``?D+ zQ!MFqOX7JbsXfQrz`-M@hq7xlfNz;_B{^wbpG8des56x(Q)H)5eLeDwCrVR}hzr~= zM{yXR6IM?kXxauLza#@#u?Y|o;904HCqF<8yT~~c-xyRc0-vxofnxG^(x%>bj5r}N zyFT+xnn-?B`ohA>{+ZZQem=*Xpqz{=j8i2TAC#x-m;;mo{{sLB_z(UoAqD=A#*juZ zCv=J~i*O8;F}A^Wf#+zx;~3B{57xtoxC&j^ie^?**T`WT2OPRtC`xj~+3Kprn=rVM zVJ|h5ux%S{dO}!mq93}P+h36mZ5aZg1-?vhL$ke1d52qIiXSE(llCr5i=QUS?LIjc zV$4q=-)aaR4wsrQv}^shL5u%6;`uiSEs<1nG^?$kl$^6DL z43CjY`M*p}ew}}3rXc7Xck@k41jx}c;NgEIhKZ*jsBRZUP-x2cm;F1<5$jefl|ppO zmZd%%?gMJ^g9=RZ^#8Mf5aWNVhjAS^|DQO+q$)oeob_&ZLFL(zur$)); zU19yRm)z<4&4-M}7!9+^Wl}Uk?`S$#V2%pQ*SIH5KI-mn%i;Z7-)m$mN9CnI$G7?# zo`zVrUwoSL&_dJ92YhX5TKqaRkfPgC4=Q&=K+;_aDs&OU0&{WFH}kKX6uNQC6%oUH z2DZa1s3%Vtk|bglbxep-w)PbFG!J17`<$g8lVhqD2w;Z0zGsh-r zxZ13G$G<48leNqR!DCVt9)@}(zMI5w6Wo=N zpP1*3DI;~h2WDWgcKn*f!+ORD)f$DZFwgKBafEZmeXQMAsq9sxP9A)7zOYnkHT9JU zRA`umgmP9d6=PHmFIgx=0$(sjb>+0CHG)K@cPG{IxaJ&Ueo8)0RWgV9+gO7+Bl1(F z7!BslJ2MP*PWJ;x)QXbR$6jEr5q3 z(3}F@YO_P1NyTdEXRLU6fp?9V2-S=E+YaeLL{Y)W%6`k7$(EW8EZSA*(+;e5@jgD^I zaJQ2|oCM1n!A&-8`;#RDcZyk*+RPkn_r8?Ak@agHiSp*qFNX)&i21HE?yuZ;-C<3C zwJGd1lx5UzViP7sZJ&|LqH*mryb}y|%AOw+v)yc`qM)03qyyrqhX?ub`Cjwx2PrR! z)_z>5*!*$x1=Qa-0uE7jy0z`>|Ni#X+uV|%_81F7)b+nf%iz=`fF4g5UfHS_?PHbr zB;0$bK@=di?f`dS(j{l3-tSCfp~zUuva+=EWxJcRfp(<$@vd(GigM&~vaYZ0c#BTs z3ijkxMl=vw5AS&DcXQ%eeKt!uKvh2l3W?&3=dBHU=Gz?O!40S&&~ei2vg**c$o;i89~6DVns zG>9a*`k5)NI9|?W!@9>rzJ;9EJ=YlJTx1r1BA?H`LWijk(rTax9(OAu;q4_wTj-yj z1%W4GW&K4T=uEGb+E!>W0SD_C0RR91 literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/web/icons/Icon-512.png b/packages/api/amplify_api/example/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/web/icons/Icon-maskable-192.png b/packages/api/amplify_api/example/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/web/icons/Icon-maskable-512.png b/packages/api/amplify_api/example/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/web/index.html b/packages/api/amplify_api/example/web/index.html new file mode 100644 index 0000000000..41b3bc336f --- /dev/null +++ b/packages/api/amplify_api/example/web/index.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + example + + + + + + + + + + diff --git a/packages/api/amplify_api/example/web/manifest.json b/packages/api/amplify_api/example/web/manifest.json new file mode 100644 index 0000000000..096edf8fe4 --- /dev/null +++ b/packages/api/amplify_api/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "example", + "short_name": "example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/packages/api/amplify_api/example/windows/.gitignore b/packages/api/amplify_api/example/windows/.gitignore new file mode 100644 index 0000000000..d492d0d98c --- /dev/null +++ b/packages/api/amplify_api/example/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/packages/api/amplify_api/example/windows/CMakeLists.txt b/packages/api/amplify_api/example/windows/CMakeLists.txt new file mode 100644 index 0000000000..c0270746b1 --- /dev/null +++ b/packages/api/amplify_api/example/windows/CMakeLists.txt @@ -0,0 +1,101 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(example LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/packages/api/amplify_api/example/windows/flutter/CMakeLists.txt b/packages/api/amplify_api/example/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000000..930d2071a3 --- /dev/null +++ b/packages/api/amplify_api/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,104 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + windows-x64 $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.cc b/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..8b6d4680af --- /dev/null +++ b/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.h b/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..dc139d85a9 --- /dev/null +++ b/packages/api/amplify_api/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/api/amplify_api/example/windows/flutter/generated_plugins.cmake b/packages/api/amplify_api/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..b93c4c30c1 --- /dev/null +++ b/packages/api/amplify_api/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/api/amplify_api/example/windows/runner/CMakeLists.txt b/packages/api/amplify_api/example/windows/runner/CMakeLists.txt new file mode 100644 index 0000000000..b9e550fba8 --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/packages/api/amplify_api/example/windows/runner/Runner.rc b/packages/api/amplify_api/example/windows/runner/Runner.rc new file mode 100644 index 0000000000..49373857ac --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#ifdef FLUTTER_BUILD_NUMBER +#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#else +#define VERSION_AS_NUMBER 1,0,0 +#endif + +#ifdef FLUTTER_BUILD_NAME +#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.amazonaws.amplify" "\0" + VALUE "FileDescription", "example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2022 com.amazonaws.amplify. All rights reserved." "\0" + VALUE "OriginalFilename", "example.exe" "\0" + VALUE "ProductName", "example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/packages/api/amplify_api/example/windows/runner/flutter_window.cpp b/packages/api/amplify_api/example/windows/runner/flutter_window.cpp new file mode 100644 index 0000000000..b43b9095ea --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/flutter_window.cpp @@ -0,0 +1,61 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/packages/api/amplify_api/example/windows/runner/flutter_window.h b/packages/api/amplify_api/example/windows/runner/flutter_window.h new file mode 100644 index 0000000000..6da0652f05 --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/packages/api/amplify_api/example/windows/runner/main.cpp b/packages/api/amplify_api/example/windows/runner/main.cpp new file mode 100644 index 0000000000..bcb57b0e2a --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.CreateAndShow(L"example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/packages/api/amplify_api/example/windows/runner/resource.h b/packages/api/amplify_api/example/windows/runner/resource.h new file mode 100644 index 0000000000..66a65d1e4a --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/packages/api/amplify_api/example/windows/runner/resources/app_icon.ico b/packages/api/amplify_api/example/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c04e20caf6370ebb9253ad831cc31de4a9c965f6 GIT binary patch literal 33772 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 literal 0 HcmV?d00001 diff --git a/packages/api/amplify_api/example/windows/runner/runner.exe.manifest b/packages/api/amplify_api/example/windows/runner/runner.exe.manifest new file mode 100644 index 0000000000..c977c4a425 --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/packages/api/amplify_api/example/windows/runner/utils.cpp b/packages/api/amplify_api/example/windows/runner/utils.cpp new file mode 100644 index 0000000000..f5bf9fa0f5 --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/utils.cpp @@ -0,0 +1,64 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, utf8_string.data(), + target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/packages/api/amplify_api/example/windows/runner/utils.h b/packages/api/amplify_api/example/windows/runner/utils.h new file mode 100644 index 0000000000..3879d54755 --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/packages/api/amplify_api/example/windows/runner/win32_window.cpp b/packages/api/amplify_api/example/windows/runner/win32_window.cpp new file mode 100644 index 0000000000..c10f08dc7d --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/win32_window.cpp @@ -0,0 +1,245 @@ +#include "win32_window.h" + +#include + +#include "resource.h" + +namespace { + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + FreeLibrary(user32_module); + } +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + return OnCreate(); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} diff --git a/packages/api/amplify_api/example/windows/runner/win32_window.h b/packages/api/amplify_api/example/windows/runner/win32_window.h new file mode 100644 index 0000000000..17ba431125 --- /dev/null +++ b/packages/api/amplify_api/example/windows/runner/win32_window.h @@ -0,0 +1,98 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates and shows a win32 window with |title| and position and size using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size to will treat the width height passed in to this function + // as logical pixels and scale to appropriate for the default monitor. Returns + // true if the window was created successfully. + bool CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responsponds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ From e6230c531295c01432fd14ab0fa67135f3bb322c Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Wed, 14 Sep 2022 15:26:40 -0500 Subject: [PATCH 37/47] chore(api): Refactor http client & cancelable operation (#2119) * chore(api): Refactor http client & cancelable operation --- .../src/category/amplify_api_category.dart | 12 +- .../plugin/amplify_api_plugin_interface.dart | 12 +- .../lib/src/types/api/api_types.dart | 1 - .../lib/src/types/api/rest/http_payload.dart | 82 ------------ .../example/lib/rest_api_view.dart | 12 +- .../amplify_authorization_rest_client.dart | 34 ++--- .../amplify_api/lib/src/api_plugin_impl.dart | 124 ++++++++---------- .../decorators/authorize_http_request.dart | 62 +++------ .../src/decorators/web_socket_auth_utils.dart | 21 ++- .../lib/src/graphql/send_graphql_request.dart | 75 ++++++----- .../lib/src/method_channel_api.dart | 116 +++++++++------- packages/api/amplify_api/pubspec.yaml | 2 + .../test/amplify_dart_rest_methods_test.dart | 44 ++++--- .../test/amplify_rest_api_methods_test.dart | 21 ++- .../test/authorize_http_request_test.dart | 8 +- .../amplify_api/test/dart_graphql_test.dart | 36 +++-- .../test/plugin_configuration_test.dart | 21 ++- packages/api/amplify_api/test/util.dart | 5 +- packages/api/amplify_api/test/util_test.dart | 4 +- .../test/ws/web_socket_auth_utils_test.dart | 2 +- .../example/lib/main.dart | 2 +- .../test/plugin/auth_providers_test.dart | 2 +- 22 files changed, 315 insertions(+), 383 deletions(-) delete mode 100644 packages/amplify_core/lib/src/types/api/rest/http_payload.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 7d9692a725..d3933999b1 100644 --- a/packages/amplify_core/lib/src/category/amplify_api_category.dart +++ b/packages/amplify_core/lib/src/category/amplify_api_category.dart @@ -43,7 +43,7 @@ class APICategory extends AmplifyCategory { // ====== RestAPI ====== - CancelableOperation delete( + AWSHttpOperation delete( String path, { Map? headers, HttpPayload? body, @@ -57,7 +57,7 @@ class APICategory extends AmplifyCategory { apiName: apiName, ); - CancelableOperation get( + AWSHttpOperation get( String path, { Map? headers, Map? queryParameters, @@ -69,7 +69,7 @@ class APICategory extends AmplifyCategory { apiName: apiName, ); - CancelableOperation head( + AWSHttpOperation head( String path, { Map? headers, Map? queryParameters, @@ -81,7 +81,7 @@ class APICategory extends AmplifyCategory { apiName: apiName, ); - CancelableOperation patch( + AWSHttpOperation patch( String path, { Map? headers, HttpPayload? body, @@ -95,7 +95,7 @@ class APICategory extends AmplifyCategory { apiName: apiName, ); - CancelableOperation post( + AWSHttpOperation post( String path, { Map? headers, HttpPayload? body, @@ -109,7 +109,7 @@ class APICategory extends AmplifyCategory { apiName: apiName, ); - CancelableOperation put( + AWSHttpOperation put( String path, { Map? headers, HttpPayload? body, 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 5169acb091..b3e5812b02 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 @@ -53,7 +53,7 @@ abstract class APIPluginInterface extends AmplifyPluginInterface { void registerAuthProvider(APIAuthProvider authProvider); // ====== RestAPI ====== - CancelableOperation delete( + AWSHttpOperation delete( String path, { HttpPayload? body, Map? headers, @@ -66,7 +66,7 @@ abstract class APIPluginInterface extends AmplifyPluginInterface { /// 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 get( + AWSHttpOperation get( String path, { Map? headers, Map? queryParameters, @@ -75,7 +75,7 @@ abstract class APIPluginInterface extends AmplifyPluginInterface { throw UnimplementedError('get() has not been implemented'); } - CancelableOperation head( + AWSHttpOperation head( String path, { Map? headers, Map? queryParameters, @@ -84,7 +84,7 @@ abstract class APIPluginInterface extends AmplifyPluginInterface { throw UnimplementedError('head() has not been implemented'); } - CancelableOperation patch( + AWSHttpOperation patch( String path, { HttpPayload? body, Map? headers, @@ -94,7 +94,7 @@ abstract class APIPluginInterface extends AmplifyPluginInterface { throw UnimplementedError('patch() has not been implemented'); } - CancelableOperation post( + AWSHttpOperation post( String path, { HttpPayload? body, Map? headers, @@ -104,7 +104,7 @@ abstract class APIPluginInterface extends AmplifyPluginInterface { throw UnimplementedError('post() has not been implemented'); } - CancelableOperation put( + AWSHttpOperation put( String path, { HttpPayload? body, Map? headers, 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 299fd03412..d5d08d70f1 100644 --- a/packages/amplify_core/lib/src/types/api/api_types.dart +++ b/packages/amplify_core/lib/src/types/api/api_types.dart @@ -27,7 +27,6 @@ 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'; 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 deleted file mode 100644 index eb657d7543..0000000000 --- a/packages/amplify_core/lib/src/types/api/rest/http_payload.dart +++ /dev/null @@ -1,82 +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. - * 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> { - 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) { - return HttpPayload.bytes(body); - } - if (body is Stream>) { - return HttpPayload.streaming(body); - } - if (body is Map) { - 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 body) : super(Stream.value(body)); - - /// A form-encoded body of `key=value` pairs. - HttpPayload.formFields(Map 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> 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 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/api/amplify_api/example/lib/rest_api_view.dart b/packages/api/amplify_api/example/lib/rest_api_view.dart index 68f8a414f1..39bfc5ea6e 100644 --- a/packages/api/amplify_api/example/lib/rest_api_view.dart +++ b/packages/api/amplify_api/example/lib/rest_api_view.dart @@ -43,7 +43,7 @@ class _RestApiViewState extends State { body: HttpPayload.json({'name': 'Mow the lawn'}), ); - _lastRestOperation = restOperation; + _lastRestOperation = restOperation.operation; final response = await restOperation.response; print('Put SUCCESS'); @@ -61,7 +61,7 @@ class _RestApiViewState extends State { body: HttpPayload.json({'name': 'Mow the lawn'}), ); - _lastRestOperation = restOperation; + _lastRestOperation = restOperation.operation; final response = await restOperation.response; print('Post SUCCESS'); @@ -78,7 +78,7 @@ class _RestApiViewState extends State { _apiPathController.text, ); - _lastRestOperation = restOperation; + _lastRestOperation = restOperation.operation; final response = await restOperation.response; print('Get SUCCESS'); @@ -94,7 +94,7 @@ class _RestApiViewState extends State { final restOperation = Amplify.API.delete( _apiPathController.text, ); - _lastRestOperation = restOperation; + _lastRestOperation = restOperation.operation; final response = await restOperation.response; print('Delete SUCCESS'); @@ -120,7 +120,7 @@ class _RestApiViewState extends State { _apiPathController.text, ); - _lastRestOperation = restOperation; + _lastRestOperation = restOperation.operation; await restOperation.response; print('Head SUCCESS'); @@ -137,7 +137,7 @@ class _RestApiViewState extends State { body: HttpPayload.json({'name': 'Mow the lawn'}), ); - _lastRestOperation = restOperation; + _lastRestOperation = restOperation.operation; final response = await restOperation.response; print('Patch SUCCESS'); 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 a0b7aece44..db928d0dee 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 @@ -14,47 +14,39 @@ import 'dart:async'; +import 'package:amplify_api/src/decorators/authorize_http_request.dart'; import 'package:amplify_core/amplify_core.dart'; -import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; -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 { +class AmplifyAuthorizationRestClient extends AWSBaseHttpClient { /// [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; - /// Provide an [AWSApiConfig] which will determine how requests from this /// client are authorized. AmplifyAuthorizationRestClient({ required this.endpointConfig, required this.authProviderRepo, - http.Client? baseClient, - }) : _useDefaultBaseClient = baseClient == null, - _baseClient = baseClient ?? http.Client(); + AWSHttpClient? baseClient, + }) : baseClient = baseClient ?? AWSHttpClient(); - /// Implementation of [send] that authorizes any request without "Authorization" - /// header already set. @override - Future send(http.BaseRequest request) async => - _baseClient.send(await authorizeHttpRequest( - request, - endpointConfig: endpointConfig, - authProviderRepo: authProviderRepo, - )); + final AWSHttpClient baseClient; + /// Implementation of [send] that authorizes any request without "Authorization" + /// header already set. @override - void close() { - if (_useDefaultBaseClient) _baseClient.close(); + Future transformRequest(AWSBaseHttpRequest request) { + return authorizeHttpRequest( + request, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); } } 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 66e0d0ca91..4a0e89d12a 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -20,9 +20,7 @@ import 'package:amplify_api/amplify_api.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'; import 'package:flutter/services.dart'; -import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'amplify_api_config.dart'; @@ -36,13 +34,13 @@ import 'util.dart'; /// {@endtemplate} class AmplifyAPIDart extends AmplifyAPI { late final AWSApiPluginConfig _apiConfig; - final http.Client? _baseHttpClient; + final AWSHttpClient? _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. - final Map _clientPool = {}; + final Map _clientPool = {}; /// A map of the keys from the Amplify API config websocket connections to use /// for that endpoint. @@ -54,7 +52,7 @@ class AmplifyAPIDart extends AmplifyAPI { /// {@macro amplify_api.amplify_api_dart} AmplifyAPIDart({ List authProviders = const [], - http.Client? baseHttpClient, + AWSHttpClient? baseHttpClient, this.modelProvider, }) : _baseHttpClient = baseHttpClient, super.protected() { @@ -116,17 +114,18 @@ class AmplifyAPIDart extends AmplifyAPI { /// /// Use [apiName] if there are multiple endpoints of the same type. @visibleForTesting - http.Client getHttpClient(EndpointType type, {String? apiName}) { + AWSHttpClient getHttpClient(EndpointType type, {String? apiName}) { final endpoint = _apiConfig.getEndpoint( type: type, apiName: apiName, ); return _clientPool[endpoint.name] ??= AmplifyHttpClient( - baseClient: AmplifyAuthorizationRestClient( - endpointConfig: endpoint.config, - baseClient: _baseHttpClient, - authProviderRepo: _authProviderRepo, - )); + baseClient: AmplifyAuthorizationRestClient( + endpointConfig: endpoint.config, + baseClient: _baseHttpClient, + authProviderRepo: _authProviderRepo, + ), + )..supportedProtocols = SupportedProtocols.http1; } /// Returns the websocket connection to use for a given endpoint. @@ -164,19 +163,6 @@ class AmplifyAPIDart extends AmplifyAPI { return endpoint.getUri(path: path, queryParameters: 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 _makeCancelable(Future responseFuture) { - return CancelableOperation.fromFuture(responseFuture); - } - - CancelableOperation _prepareRestResponse( - Future responseFuture) { - return _makeCancelable(responseFuture); - } - @override final ModelProviderInterface? modelProvider; @@ -194,9 +180,11 @@ class AmplifyAPIDart extends AmplifyAPI { getHttpClient(EndpointType.graphQL, apiName: request.apiName); final uri = _getGraphQLUri(request.apiName); - final responseFuture = sendGraphQLRequest( - request: request, client: graphQLClient, uri: uri); - return _makeCancelable>(responseFuture); + return sendGraphQLRequest( + request: request, + client: graphQLClient, + uri: uri, + ); } @override @@ -206,9 +194,11 @@ class AmplifyAPIDart extends AmplifyAPI { getHttpClient(EndpointType.graphQL, apiName: request.apiName); final uri = _getGraphQLUri(request.apiName); - final responseFuture = sendGraphQLRequest( - request: request, client: graphQLClient, uri: uri); - return _makeCancelable>(responseFuture); + return sendGraphQLRequest( + request: request, + client: graphQLClient, + uri: uri, + ); } @override @@ -223,7 +213,7 @@ class AmplifyAPIDart extends AmplifyAPI { // ====== REST ======= @override - CancelableOperation delete( + AWSHttpOperation delete( String path, { HttpPayload? body, Map? headers, @@ -232,15 +222,15 @@ class AmplifyAPIDart extends AmplifyAPI { }) { final uri = _getRestUri(path, apiName, queryParameters); final client = getHttpClient(EndpointType.rest, apiName: apiName); - return _prepareRestResponse(AWSStreamedHttpRequest.delete( + return AWSStreamedHttpRequest.delete( uri, - body: body ?? HttpPayload.empty(), + body: body, headers: addContentTypeToHeaders(headers, body), - ).send(client)); + ).send(client); } @override - CancelableOperation get( + AWSHttpOperation get( String path, { Map? headers, Map? queryParameters, @@ -248,16 +238,14 @@ class AmplifyAPIDart extends AmplifyAPI { }) { final uri = _getRestUri(path, apiName, queryParameters); final client = getHttpClient(EndpointType.rest, apiName: apiName); - return _prepareRestResponse( - AWSHttpRequest.get( - uri, - headers: headers, - ).send(client), - ); + return AWSHttpRequest.get( + uri, + headers: headers, + ).send(client); } @override - CancelableOperation head( + AWSHttpOperation head( String path, { Map? headers, Map? queryParameters, @@ -265,16 +253,14 @@ class AmplifyAPIDart extends AmplifyAPI { }) { final uri = _getRestUri(path, apiName, queryParameters); final client = getHttpClient(EndpointType.rest, apiName: apiName); - return _prepareRestResponse( - AWSHttpRequest.head( - uri, - headers: headers, - ).send(client), - ); + return AWSHttpRequest.head( + uri, + headers: headers, + ).send(client); } @override - CancelableOperation patch( + AWSHttpOperation patch( String path, { HttpPayload? body, Map? headers, @@ -283,17 +269,15 @@ class AmplifyAPIDart extends AmplifyAPI { }) { final uri = _getRestUri(path, apiName, queryParameters); final client = getHttpClient(EndpointType.rest, apiName: apiName); - return _prepareRestResponse( - AWSStreamedHttpRequest.patch( - uri, - headers: addContentTypeToHeaders(headers, body), - body: body ?? HttpPayload.empty(), - ).send(client), - ); + return AWSStreamedHttpRequest.patch( + uri, + headers: addContentTypeToHeaders(headers, body), + body: body ?? const HttpPayload.empty(), + ).send(client); } @override - CancelableOperation post( + AWSHttpOperation post( String path, { HttpPayload? body, Map? headers, @@ -302,17 +286,15 @@ class AmplifyAPIDart extends AmplifyAPI { }) { final uri = _getRestUri(path, apiName, queryParameters); final client = getHttpClient(EndpointType.rest, apiName: apiName); - return _prepareRestResponse( - AWSStreamedHttpRequest.post( - uri, - headers: addContentTypeToHeaders(headers, body), - body: body ?? HttpPayload.empty(), - ).send(client), - ); + return AWSStreamedHttpRequest.post( + uri, + headers: addContentTypeToHeaders(headers, body), + body: body ?? const HttpPayload.empty(), + ).send(client); } @override - CancelableOperation put( + AWSHttpOperation put( String path, { HttpPayload? body, Map? headers, @@ -321,12 +303,10 @@ class AmplifyAPIDart extends AmplifyAPI { }) { final uri = _getRestUri(path, apiName, queryParameters); final client = getHttpClient(EndpointType.rest, apiName: apiName); - return _prepareRestResponse( - AWSStreamedHttpRequest.put( - uri, - headers: addContentTypeToHeaders(headers, body), - body: body ?? HttpPayload.empty(), - ).send(client), - ); + return AWSStreamedHttpRequest.put( + uri, + headers: addContentTypeToHeaders(headers, body), + body: body ?? const HttpPayload.empty(), + ).send(client); } } 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 24a343895e..f3fe000c1f 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 @@ -15,15 +15,16 @@ 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 authorizeHttpRequest(http.BaseRequest request, - {required AWSApiConfig endpointConfig, - required AmplifyAuthProviderRepository authProviderRepo}) async { +Future authorizeHttpRequest( + AWSBaseHttpRequest request, { + required AWSApiConfig endpointConfig, + required AmplifyAuthProviderRepository authProviderRepo, +}) async { if (request.headers.containsKey(AWSHeaders.authorization)) { return request; } @@ -32,9 +33,11 @@ Future authorizeHttpRequest(http.BaseRequest request, switch (authType) { case APIAuthorizationType.apiKey: final authProvider = _validateAuthProvider( - authProviderRepo - .getAuthProvider(APIAuthorizationType.apiKey.authProviderToken), - authType); + authProviderRepo.getAuthProvider( + APIAuthorizationType.apiKey.authProviderToken, + ), + authType, + ); final apiKey = endpointConfig.apiKey; if (apiKey == null) { throw const ApiException( @@ -42,9 +45,10 @@ Future authorizeHttpRequest(http.BaseRequest request, } final authorizedRequest = await authProvider.authorizeRequest( - _httpToAWSRequest(request), - options: ApiKeyAuthProviderOptions(apiKey)); - return authorizedRequest.httpRequest; + request, + options: ApiKeyAuthProviderOptions(apiKey), + ); + return authorizedRequest; case APIAuthorizationType.iam: final authProvider = _validateAuthProvider( authProviderRepo @@ -55,13 +59,13 @@ Future authorizeHttpRequest(http.BaseRequest request, : AWSService.apiGatewayManagementApi; // resolves to "execute-api" final authorizedRequest = await authProvider.authorizeRequest( - _httpToAWSRequest(request), + request, options: IamAuthProviderOptions( region: endpointConfig.region, service: service, ), ); - return authorizedRequest.httpRequest; + return authorizedRequest; case APIAuthorizationType.function: case APIAuthorizationType.oidc: throw UnimplementedError('${authType.name} not implemented.'); @@ -70,9 +74,8 @@ Future authorizeHttpRequest(http.BaseRequest request, authProviderRepo.getAuthProvider(authType.authProviderToken), authType, ); - final authorizedRequest = - await authProvider.authorizeRequest(_httpToAWSRequest(request)); - return authorizedRequest.httpRequest; + final authorizedRequest = await authProvider.authorizeRequest(request); + return authorizedRequest; case APIAuthorizationType.none: return request; } @@ -86,32 +89,3 @@ T _validateAuthProvider( } 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/decorators/web_socket_auth_utils.dart b/packages/api/amplify_api/lib/src/decorators/web_socket_auth_utils.dart index d1520c731e..eb3f933c68 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 @@ -18,7 +18,6 @@ library amplify_api.decorators.web_socket_auth_utils; 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/web_socket_types.dart'; @@ -28,11 +27,11 @@ import 'authorize_http_request.dart'; const _requiredHeaders = { AWSHeaders.accept: 'application/json, text/javascript', AWSHeaders.contentEncoding: 'amz-1.0', - AWSHeaders.contentType: 'application/json; charset=UTF-8', + AWSHeaders.contentType: 'application/json; charset=utf-8', }; // AppSync expects "{}" encoded in the URI as the payload during handshake. -const _emptyBody = '{}'; +const _emptyBody = {}; /// Generate a URI for the connection and all subscriptions. /// @@ -53,7 +52,7 @@ Future generateConnectionUri( return Uri(scheme: 'wss', host: endpointUri.host, path: 'graphql') .replace(queryParameters: { 'header': encodedAuthHeaders, - 'payload': base64.encode(utf8.encode(_emptyBody)), + 'payload': base64.encode(utf8.encode(json.encode(_emptyBody))), }); } @@ -67,8 +66,7 @@ Future required AmplifyAuthProviderRepository authRepo, required GraphQLRequest request, }) async { - final body = - jsonEncode({'variables': request.variables, 'query': request.document}); + final body = {'variables': request.variables, 'query': request.document}; final authorizationHeaders = await _generateAuthorizationHeaders( config, isConnectionInit: false, @@ -99,7 +97,7 @@ Future> _generateAuthorizationHeaders( AWSApiConfig config, { required bool isConnectionInit, required AmplifyAuthProviderRepository authRepo, - required String body, + required Map body, }) async { final endpointHost = Uri.parse(config.endpoint).host; // Create canonical HTTP request to authorize but never send. @@ -107,10 +105,11 @@ Future> _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 canonicalHttpRequest = - http.Request('POST', Uri.parse('${config.endpoint}$maybeConnect')); - canonicalHttpRequest.headers.addAll(_requiredHeaders); - canonicalHttpRequest.body = body; + final canonicalHttpRequest = AWSStreamedHttpRequest.post( + Uri.parse('${config.endpoint}$maybeConnect'), + headers: _requiredHeaders, + body: HttpPayload.json(body), + ); final authorizedHttpRequest = await authorizeHttpRequest( canonicalHttpRequest, endpointConfig: config, 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 3ba0a36c7d..a942d08fb8 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 @@ -16,7 +16,6 @@ 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'; @@ -24,35 +23,51 @@ import 'graphql_response_decoder.dart'; /// Converts the [GraphQLRequest] to an HTTP POST request and sends with ///[client]. @internal -Future> sendGraphQLRequest({ +CancelableOperation> sendGraphQLRequest({ required GraphQLRequest request, - required http.Client client, + required AWSHttpClient client, required Uri uri, -}) async { - try { - final body = {'variables': request.variables, 'query': request.document}; - final graphQLResponse = await client.post(uri, - body: json.encode(body), headers: request.headers); - - final responseBody = json.decode(graphQLResponse.body); - - if (responseBody is! Map) { - 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(request: request, data: responseDataJson, errors: errors); - } on Exception catch (e) { - throw ApiException('unable to send GraphQLRequest to client.', - underlyingException: e.toString()); - } +}) { + final body = {'variables': request.variables, 'query': request.document}; + final graphQLOperation = client.send(AWSStreamedHttpRequest.post( + uri, + body: HttpPayload.json(body), + headers: request.headers, + )); + + return graphQLOperation.operation.then( + (response) async { + final responseJson = await response.decodeBody(); + final responseBody = json.decode(responseJson); + + if (responseBody is! Map) { + throw ApiException( + 'unable to parse GraphQLResponse from server response which was ' + 'not a JSON object: $responseJson', + ); + } + + 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( + request: request, + data: responseDataJson, + errors: errors, + ); + }, + onError: (error, stackTrace) { + Error.throwWithStackTrace( + ApiException( + 'unable to send GraphQLRequest to client.', + underlyingException: error, + ), + stackTrace, + ); + }, + ); } 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 45f5eb862f..e5dc0b9b09 100644 --- a/packages/api/amplify_api/lib/src/method_channel_api.dart +++ b/packages/api/amplify_api/lib/src/method_channel_api.dart @@ -333,106 +333,130 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { } @override - CancelableOperation get( + AWSHttpOperation get( String path, { Map? headers, Map? queryParameters, String? apiName, }) { - return _restFunctionHelper( - methodName: 'get', - path: path, - headers: headers, - queryParameters: queryParameters, - apiName: apiName, + return AWSHttpOperation( + _restFunctionHelper( + methodName: 'get', + path: path, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ), + requestProgress: const Stream.empty(), + responseProgress: const Stream.empty(), ); } @override - CancelableOperation put( + AWSHttpOperation put( String path, { HttpPayload? body, Map? headers, Map? queryParameters, String? apiName, }) { - return _restFunctionHelper( - methodName: 'put', - path: path, - body: body, - headers: headers, - queryParameters: queryParameters, - apiName: apiName, + return AWSHttpOperation( + _restFunctionHelper( + methodName: 'put', + path: path, + body: body, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ), + requestProgress: const Stream.empty(), + responseProgress: const Stream.empty(), ); } @override - CancelableOperation post( + AWSHttpOperation post( String path, { HttpPayload? body, Map? headers, Map? queryParameters, String? apiName, }) { - return _restFunctionHelper( - methodName: 'post', - path: path, - body: body, - headers: headers, - queryParameters: queryParameters, - apiName: apiName, + return AWSHttpOperation( + _restFunctionHelper( + methodName: 'post', + path: path, + body: body, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ), + requestProgress: const Stream.empty(), + responseProgress: const Stream.empty(), ); } @override - CancelableOperation delete( + AWSHttpOperation delete( String path, { HttpPayload? body, Map? headers, Map? queryParameters, String? apiName, }) { - return _restFunctionHelper( - methodName: 'delete', - path: path, - body: body, - headers: headers, - queryParameters: queryParameters, - apiName: apiName, + return AWSHttpOperation( + _restFunctionHelper( + methodName: 'delete', + path: path, + body: body, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ), + requestProgress: const Stream.empty(), + responseProgress: const Stream.empty(), ); } @override - CancelableOperation head( + AWSHttpOperation head( String path, { Map? headers, Map? queryParameters, String? apiName, }) { - return _restFunctionHelper( - methodName: 'head', - path: path, - headers: headers, - queryParameters: queryParameters, - apiName: apiName, + return AWSHttpOperation( + _restFunctionHelper( + methodName: 'head', + path: path, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ), + requestProgress: const Stream.empty(), + responseProgress: const Stream.empty(), ); } @override - CancelableOperation patch( + AWSHttpOperation patch( String path, { HttpPayload? body, Map? headers, Map? queryParameters, String? apiName, }) { - return _restFunctionHelper( - methodName: 'patch', - path: path, - body: body, - headers: headers, - queryParameters: queryParameters, - apiName: apiName, + return AWSHttpOperation( + _restFunctionHelper( + methodName: 'patch', + path: path, + body: body, + headers: headers, + queryParameters: queryParameters, + apiName: apiName, + ), + requestProgress: const Stream.empty(), + responseProgress: const Stream.empty(), ); } diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index 5b9b7e2865..2dac04d33a 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -35,10 +35,12 @@ dev_dependencies: async: ^2.8.0 aws_signature_v4: ^0.1.0 build_runner: ^2.0.0 + connectivity_plus_platform_interface: ^1.2.0 flutter_test: sdk: flutter mocktail: ^0.3.0 pigeon: ^3.1.6 + stream_channel: ^2.1.0 # The following section is specific to Flutter. 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 8469354830..cbb58a56c9 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,13 +11,13 @@ // 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:aws_common/testing.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'; @@ -26,15 +26,22 @@ const _pathThatShouldFail = 'notHere'; class MockAmplifyAPI extends AmplifyAPIDart { @override - http.Client getHttpClient(EndpointType type, {String? apiName}) => - MockClient((request) async { - if (request.body.isNotEmpty) { - expect(request.headers['Content-Type'], 'application/json'); + AWSHttpClient getHttpClient(EndpointType type, {String? apiName}) => + MockAWSHttpClient((request) async { + if (request.bodyBytes.isNotEmpty) { + expect(request.headers['Content-Type'], + 'application/json; charset=utf-8'); } - if (request.url.path.contains(_pathThatShouldFail)) { - return http.Response('Not found', 404); + if (request.host.contains(_pathThatShouldFail)) { + return AWSHttpResponse( + statusCode: 404, + body: utf8.encode('Not found'), + ); } - return http.Response(_expectedRestResponseBody, 200); + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode(_expectedRestResponseBody), + ); }); } @@ -47,10 +54,10 @@ void main() { }); group('REST API', () { Future _verifyRestOperation( - CancelableOperation operation, + AWSHttpOperation operation, ) async { final response = - await operation.value.timeout(const Duration(seconds: 3)); + await operation.response.timeout(const Duration(seconds: 3)); final body = await response.decodeBody(); expect(body, _expectedRestResponseBody); expect(response.statusCode, 200); @@ -63,7 +70,7 @@ void main() { test('head() should get 200', () async { final operation = Amplify.API.head('items'); - final response = await operation.value; + final response = await operation.response; expect(response.statusCode, 200); }); @@ -94,9 +101,14 @@ void main() { 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); + operation.operation + .then((p0) => fail('Request should have been cancelled.')); + + await expectLater( + operation.response, + throwsA(isA()), + ); + expect(operation.operation.isCanceled, isTrue); }); }); } 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 5106ada1c2..e684b8fa31 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 @@ -14,7 +14,6 @@ */ import 'dart:convert'; -import 'dart:typed_data'; import 'package:amplify_api/amplify_api.dart'; import 'package:amplify_flutter/amplify_flutter.dart'; @@ -52,7 +51,7 @@ void main() { await Amplify.addPlugin(api); }); - Future _assertResponse(AWSStreamedHttpResponse response) async { + Future _assertResponse(AWSBaseHttpResponse response) async { final actualResponseBody = await response.decodeBody(); expect(actualResponseBody, hello); expect(response.statusCode, statusOK); @@ -80,7 +79,7 @@ void main() { headers: headers, ); - final response = await restOperation.value; + final response = await restOperation.response; await _assertResponse(response); }); @@ -106,7 +105,7 @@ void main() { headers: headers, ); - final response = await restOperation.value; + final response = await restOperation.response; await _assertResponse(response); }); @@ -130,7 +129,7 @@ void main() { headers: headers, ); - final response = await restOperation.value; + final response = await restOperation.response; await _assertResponse(response); }); @@ -156,7 +155,7 @@ void main() { headers: headers, ); - final response = await restOperation.value; + final response = await restOperation.response; await _assertResponse(response); }); @@ -171,7 +170,7 @@ void main() { expect(restOptions['path'], '/items'); expect(restOptions['queryParameters'], queryParameters); expect(restOptions['headers'][AWSHeaders.contentType], - 'application/x-www-form-urlencoded'); + 'application/x-www-form-urlencoded; charset=utf-8'); expect(utf8.decode(restOptions['body'] as List), 'foo=bar'); return { 'data': helloResponse, @@ -188,7 +187,7 @@ void main() { queryParameters: queryParameters, ); - final response = await restOperation.value; + final response = await restOperation.response; expect(response.headers['foo'], 'bar'); await _assertResponse(response); }); @@ -203,8 +202,8 @@ void main() { expect(restOptions['apiName'], 'restapi'); expect(restOptions['path'], '/items'); expect(restOptions['queryParameters'], queryParameters); - expect( - restOptions['headers'][AWSHeaders.contentType], 'application/json'); + expect(restOptions['headers'][AWSHeaders.contentType], + 'application/json; charset=utf-8'); expect(utf8.decode(restOptions['body'] as List), '{"foo":"bar"}'); return { 'data': helloResponse, @@ -221,7 +220,7 @@ void main() { queryParameters: queryParameters, ); - final response = await restOperation.value; + final response = await restOperation.response; await _assertResponse(response); }); 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 3f1ad3754d..32035b9af4 100644 --- a/packages/api/amplify_api/test/authorize_http_request_test.dart +++ b/packages/api/amplify_api/test/authorize_http_request_test.dart @@ -15,7 +15,6 @@ 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'; @@ -23,8 +22,11 @@ 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)); +AWSHttpRequest _generateTestRequest(String url) { + return AWSHttpRequest( + method: AWSHttpMethod.get, + uri: Uri.parse(url), + ); } void main() { diff --git a/packages/api/amplify_api/test/dart_graphql_test.dart b/packages/api/amplify_api/test/dart_graphql_test.dart index b37a2611f3..46e5ff8eeb 100644 --- a/packages/api/amplify_api/test/dart_graphql_test.dart +++ b/packages/api/amplify_api/test/dart_graphql_test.dart @@ -20,10 +20,9 @@ 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:aws_common/testing.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'; import 'util.dart'; @@ -94,21 +93,32 @@ class MockAmplifyAPI extends AmplifyAPIDart { }) : super(modelProvider: modelProvider); @override - http.Client getHttpClient(EndpointType type, {String? apiName}) => - MockClient((request) async { - if (request.body.contains('getBlog')) { - return http.Response(json.encode(_expectedModelQueryResult), 200); + AWSHttpClient getHttpClient(EndpointType type, {String? apiName}) => + MockAWSHttpClient((request) async { + final body = await utf8.decodeStream(request.body); + if (body.contains('getBlog')) { + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode(json.encode(_expectedModelQueryResult)), + ); } - if (request.body.contains('TestMutate')) { - return http.Response( - json.encode(_expectedMutateSuccessResponseBody), 400); + if (body.contains('TestMutate')) { + return AWSHttpResponse( + statusCode: 400, + body: utf8.encode(json.encode(_expectedMutateSuccessResponseBody)), + ); } - if (request.body.contains('TestError')) { - return http.Response(json.encode(_expectedErrorResponseBody), 400); + if (body.contains('TestError')) { + return AWSHttpResponse( + statusCode: 400, + body: utf8.encode(json.encode(_expectedErrorResponseBody)), + ); } - return http.Response( - json.encode(_expectedQuerySuccessResponseBody), 200); + return AWSHttpResponse( + statusCode: 400, + body: utf8.encode((json.encode(_expectedQuerySuccessResponseBody))), + ); }); @override diff --git a/packages/api/amplify_api/test/plugin_configuration_test.dart b/packages/api/amplify_api/test/plugin_configuration_test.dart index fcf3692114..b05bf4a48f 100644 --- a/packages/api/amplify_api/test/plugin_configuration_test.dart +++ b/packages/api/amplify_api/test/plugin_configuration_test.dart @@ -16,9 +16,8 @@ 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:aws_common/testing.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'; @@ -38,21 +37,29 @@ const _expectedQuerySuccessResponseBody = { }; /// Asserts user agent and API key present. -final _mockGqlClient = MockClient((request) async { +final _mockGqlClient = MockAWSHttpClient((request) async { const userAgentHeader = zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; expect(request.headers[userAgentHeader], contains('amplify-flutter')); expect(request.headers[xApiKey], isA()); - return http.Response(json.encode(_expectedQuerySuccessResponseBody), 200); + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode( + json.encode(_expectedQuerySuccessResponseBody), + ), + ); }); /// Asserts user agent and signed. -final _mockRestClient = MockClient((request) async { +final _mockRestClient = MockAWSHttpClient((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); + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode('"Hello from Lambda!"'), + ); }); void main() { @@ -105,7 +112,7 @@ void main() { await plugin.configure( authProviderRepo: authProviderRepo, config: config); - await plugin.get('/items').value; + await plugin.get('/items').response; // 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 index 7da7c56c1b..2e80a0a211 100644 --- a/packages/api/amplify_api/test/util.dart +++ b/packages/api/amplify_api/test/util.dart @@ -22,7 +22,6 @@ 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; const testAccessToken = 'test-access-token-123'; @@ -59,7 +58,7 @@ class TestTokenAuthProvider extends TokenAmplifyAuthProvider { } } -void validateSignedRequest(http.BaseRequest request) { +void validateSignedRequest(AWSBaseHttpRequest request) { const userAgentHeader = zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; expect( @@ -77,7 +76,7 @@ const testApiKeyConfig = AWSApiConfig( ); const expectedApiKeyWebSocketConnectionUrl = - 'wss://abc123.appsync-realtime-api.us-east-1.amazonaws.com/graphql?header=eyJDb250ZW50LVR5cGUiOiJhcHBsaWNhdGlvbi9qc29uOyBjaGFyc2V0PVVURi04IiwiWC1BcGktS2V5IjoiYWJjLTEyMyIsIkFjY2VwdCI6ImFwcGxpY2F0aW9uL2pzb24sIHRleHQvamF2YXNjcmlwdCIsIkNvbnRlbnQtRW5jb2RpbmciOiJhbXotMS4wIiwiSG9zdCI6ImFiYzEyMy5hcHBzeW5jLWFwaS51cy1lYXN0LTEuYW1hem9uYXdzLmNvbSJ9&payload=e30%3D'; + 'wss://abc123.appsync-realtime-api.us-east-1.amazonaws.com/graphql?header=eyJBY2NlcHQiOiJhcHBsaWNhdGlvbi9qc29uLCB0ZXh0L2phdmFzY3JpcHQiLCJDb250ZW50LUVuY29kaW5nIjoiYW16LTEuMCIsIkNvbnRlbnQtVHlwZSI6ImFwcGxpY2F0aW9uL2pzb247IGNoYXJzZXQ9dXRmLTgiLCJYLUFwaS1LZXkiOiJhYmMtMTIzIiwiSG9zdCI6ImFiYzEyMy5hcHBzeW5jLWFwaS51cy1lYXN0LTEuYW1hem9uYXdzLmNvbSJ9&payload=e30%3D'; AmplifyAuthProviderRepository getTestAuthProviderRepo() { final testAuthProviderRepo = AmplifyAuthProviderRepository(); diff --git a/packages/api/amplify_api/test/util_test.dart b/packages/api/amplify_api/test/util_test.dart index 062b8f7276..28c1d7677c 100644 --- a/packages/api/amplify_api/test/util_test.dart +++ b/packages/api/amplify_api/test/util_test.dart @@ -22,7 +22,7 @@ void main() { test('adds Content-Type header from payload', () { final resultHeaders = addContentTypeToHeaders( null, HttpPayload.json({'name': 'now the lawn'})); - expect(resultHeaders?['Content-Type'], 'application/json'); + expect(resultHeaders?['Content-Type'], 'application/json; charset=utf-8'); }); test('no-op when payload null', () { @@ -35,7 +35,7 @@ void main() { final inputHeaders = {'foo': 'bar'}; final resultHeaders = addContentTypeToHeaders( inputHeaders, HttpPayload.json({'name': 'now the lawn'})); - expect(resultHeaders?['Content-Type'], 'application/json'); + expect(resultHeaders?['Content-Type'], 'application/json; charset=utf-8'); expect(inputHeaders['Content-Type'], isNull); }); }); 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 index 19cb61a647..c9cfa0c90d 100644 --- 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 @@ -41,7 +41,7 @@ void main() { SubscriptionRegistrationPayload payload) { expect( payload.authorizationHeaders[AWSHeaders.contentType], - 'application/json; charset=UTF-8', + 'application/json; charset=utf-8', ); expect( payload.authorizationHeaders[AWSHeaders.accept], diff --git a/packages/auth/amplify_auth_cognito/example/lib/main.dart b/packages/auth/amplify_auth_cognito/example/lib/main.dart index fe282ac4cf..000d9ebad3 100644 --- a/packages/auth/amplify_auth_cognito/example/lib/main.dart +++ b/packages/auth/amplify_auth_cognito/example/lib/main.dart @@ -178,7 +178,7 @@ class _HomeScreenState extends State { '/hello', body: HttpPayload.string(_controller.text), ) - .value; + .response; final decodedBody = await response.decodeBody(); setState(() { _greeting = decodedBody; diff --git a/packages/auth/amplify_auth_cognito_test/test/plugin/auth_providers_test.dart b/packages/auth/amplify_auth_cognito_test/test/plugin/auth_providers_test.dart index de1d20b496..2a39867c96 100644 --- a/packages/auth/amplify_auth_cognito_test/test/plugin/auth_providers_test.dart +++ b/packages/auth/amplify_auth_cognito_test/test/plugin/auth_providers_test.dart @@ -34,7 +34,7 @@ AWSHttpRequest _generateTestRequest() { /// Mock implementation of user pool only error when trying to get credentials. class TestAmplifyAuthUserPoolOnly extends AmplifyAuthCognitoDart { @override - Future fetchAuthSession({ + Future fetchAuthSession({ required AuthSessionRequest request, }) async { final options = request.options as CognitoSessionOptions?; From 543155242097dbbc2471d4343698978298511bea Mon Sep 17 00:00:00 2001 From: equartey Date: Wed, 14 Sep 2022 15:59:14 -0500 Subject: [PATCH 38/47] fix: unit tests sans-reconnect & hub events --- .../amplify_api/lib/src/api_plugin_impl.dart | 3 +- .../src/graphql/ws/web_socket_connection.dart | 108 ++++++---- .../amplify_api/test/dart_graphql_test.dart | 6 +- packages/api/amplify_api/test/util.dart | 144 +++++++++----- .../test/ws/web_socket_connection_test.dart | 185 ++++++++++++++---- 5 files changed, 320 insertions(+), 126 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 56736de7c2..fec61159b5 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -58,7 +58,6 @@ class AmplifyAPIDart extends AmplifyAPI { AWSHttpClient? baseHttpClient, this.modelProvider, this.subscriptionOptions, - }) : _baseHttpClient = baseHttpClient, super.protected() { authProviders.forEach(registerAuthProvider); @@ -146,7 +145,7 @@ class AmplifyAPIDart extends AmplifyAPI { return _webSocketConnectionPool[endpoint.name] ??= WebSocketConnection( endpoint.config, _authProviderRepo, - subscriptionOptions, + subscriptionOptions: subscriptionOptions, logger: _logger.createChild( 'webSocketConnection${endpoint.name}', ), 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 7b79f85e71..2c75539680 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 @@ -34,6 +34,8 @@ const _defaultCloseStatus = status.goingAway; const Duration _defaultPingInterval = Duration(seconds: 30); const Duration _defaultRetryTimeout = Duration(seconds: 5); +typedef WebSocketFactory = WebSocketChannel Function(Uri connectionUri); + /// {@template amplify_api.ws.web_socket_connection} /// Manages connection with an AppSync backend and subscription routing. /// {@endtemplate} @@ -50,15 +52,17 @@ 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. - WebSocketChannel? _channel; + // Other events (for single subscriptions) rebroadcast to rebroadcastController. + @visibleForTesting + WebSocketChannel? channel; + StreamSubscription? _subscription; StreamSubscription? _network; final StreamController _hubEventsController = StreamController.broadcast(); RestartableTimer? _timeoutTimer; - final http.Client _pingClient = http.Client(); + late http.Client _pingClient; Timer? _pingTimer; late Uri _pingUri; bool _hasNetwork = false; @@ -69,26 +73,54 @@ class WebSocketConnection implements Closeable { ); /// Subscription options - final GraphQLSubscriptionOptions? _subscriptionOptions; - // Re-broadcasts incoming messages for child streams (single GraphQL subscriptions). - // start_ack, data, error - final StreamController _rebroadcastController = + late final GraphQLSubscriptionOptions? _subscriptionOptions; + final Duration _defaultPingInterval = const Duration(seconds: 30); + final Duration _defaultRetryTimeout = const Duration(seconds: 5); + + // Overrides + late final Connectivity? _connectivityOverride; + late final WebSocketFactory? _webSocketFactoryOverride; + + /// Re-broadcasts incoming messages for child streams (single GraphQL subscriptions). + /// start_ack, data, error + @visibleForTesting + final StreamController rebroadcastController = StreamController.broadcast(); - Stream get _messageStream => _rebroadcastController.stream; + Stream get _messageStream => rebroadcastController.stream; // Manage initial connection state. var _initMemo = AsyncMemoizer(); + Completer _connectionPending = Completer(); Completer _connectionReady = Completer(); + final Completer _connectionStart = Completer(); + + /// Fires when connection is waiting for the first `connection_ack` message. + @visibleForTesting + Future get connectionPending => _connectionPending.future; /// Fires when the connection is ready to be listened to after the first /// `connection_ack` message. Future get ready => _connectionReady.future; + /// Fires when connection is waiting for the `start_ack` message. + @visibleForTesting + Future get connectionStart => _connectionStart.future; + /// {@macro amplify_api.ws.web_socket_connection} WebSocketConnection( - this._config, this._authProviderRepo, this._subscriptionOptions, - {required AmplifyLogger logger}) { + this._config, + this._authProviderRepo, { + required AmplifyLogger logger, + GraphQLSubscriptionOptions? subscriptionOptions, + @visibleForTesting WebSocketFactory? webSocketFactoryOverride, + @visibleForTesting http.Client? clientOverride, + @visibleForTesting Connectivity? connectivityOverride, + }) { _logger = logger; + _subscriptionOptions = subscriptionOptions; + _pingClient = clientOverride ?? http.Client(); + _connectivityOverride = connectivityOverride; + _webSocketFactoryOverride = webSocketFactoryOverride; _pingUri = Uri.parse(_config.endpoint).replace(path: 'ping'); // Establish HubEvents Stream @@ -107,16 +139,17 @@ class WebSocketConnection implements Closeable { _resetConnectionInit(); _resetConnectionVariables(); }, - onError: (Object e) { - _hubEventsController.add(SubscriptionHubEvent.failed()); - _connectionError( - ApiException('Connection failed.', underlyingException: e), - ); - + onError: (Object e, StackTrace st) { if (_hasNetwork && _subscriptionRequests.isNotEmpty) { _retry(); } else { _hubEventsController.add(SubscriptionHubEvent.failed()); + _connectionError( + Error.throwWithStackTrace( + ApiException('Connection failed.', underlyingException: e), + st, + ), + ); } }, // Todo(equartey): investigate why this breaks onDone & onError when an error occurs. @@ -127,7 +160,7 @@ class WebSocketConnection implements Closeable { /// Connects _network stream for connection status. @visibleForTesting StreamSubscription getStreamNetwork() { - return Connectivity() + return (_connectivityOverride ?? Connectivity()) .onConnectivityChanged .listen((ConnectivityResult connectivityResult) async { switch (connectivityResult) { @@ -153,12 +186,12 @@ class WebSocketConnection implements Closeable { /// init message. @visibleForTesting Future connect(Uri connectionUri) async { - _channel = WebSocketChannel.connect( - connectionUri, - protocols: webSocketProtocols, - ); - _subscription = getStreamSubscription(_channel!.stream); - + channel = _webSocketFactoryOverride?.call(connectionUri) ?? + WebSocketChannel.connect( + connectionUri, + protocols: webSocketProtocols, + ); + _subscription = getStreamSubscription(channel!.stream); _network = getStreamNetwork(); } @@ -166,8 +199,9 @@ class WebSocketConnection implements Closeable { if (!_connectionReady.isCompleted) { _connectionReady.completeError(exception); } - _channel?.sink.close(); + channel?.sink.close(); _resetConnectionInit(); + _resetConnectionVariables(); } // Reset connection init variables so it can be re-attempted. @@ -269,17 +303,17 @@ class WebSocketConnection implements Closeable { final reason = closeStatus == _defaultCloseStatus ? 'client closed' : 'unknown'; - _channel?.sink.done.whenComplete(() { + channel?.sink.done.whenComplete(() { _network?.cancel(); _subscription?.cancel(); _timeoutTimer?.cancel(); - _channel = null; + channel = null; _network = null; _subscription = null; _timeoutTimer = null; }); - _channel?.sink.close(closeStatus, reason); + channel?.sink.close(closeStatus, reason); } /// Initializes the connection. @@ -292,6 +326,7 @@ class WebSocketConnection implements Closeable { Future _init() async { // Prep new connection with a clean slate _connectionReady = Completer(); + _connectionPending = Completer(); _resetConnectionVariables(); _hubEventsController.add(SubscriptionHubEvent.connecting()); @@ -300,6 +335,8 @@ class WebSocketConnection implements Closeable { await generateConnectionUri(_config, _authProviderRepo); await connect(connectionUri); + _connectionPending.complete(); + send(WebSocketConnectionInitMessage()); await ready; @@ -364,6 +401,7 @@ class WebSocketConnection implements Closeable { ); _sendSubscriptionRegistrationMessage(request).catchError((Object e) { + _logger.error(e.toString()); controller.addError(e); _hubEventsController.add(SubscriptionHubEvent.failed()); }); @@ -375,21 +413,21 @@ class WebSocketConnection implements Closeable { _logger.info('Attempting to cancel Operation $subscriptionId'); send(WebSocketStopMessage(id: subscriptionId)); _subscriptionRequests.removeWhere((r) => r.id == subscriptionId); - if (_subscriptionRequests.isEmpty) close(status.normalClosure); + // if (_subscriptionRequests.isEmpty) close(status.normalClosure); } /// Serializes a message as JSON string and sends over WebSocket channel. @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). void _timeout(Duration timeoutDuration) { _timeoutTimer?.cancel(); _hubEventsController.add(SubscriptionHubEvent.disconnected()); - _rebroadcastController.addError( + rebroadcastController.addError( TimeoutException( 'Connection timeout', timeoutDuration, @@ -401,10 +439,10 @@ class WebSocketConnection implements Closeable { /// Handles incoming data on the WebSocket. /// /// Here, handle connection-wide messages and pass subscription events to - /// `_rebroadcastController`. + /// `rebroadcastController`. void _onData(WebSocketMessage message) { - _logger.verbose('websocket received message: ${prettyPrintJson(message)}'); - + // _logger.verbose('websocket received message: ${prettyPrintJson(message)}'); + print('onData: $message'); switch (message.messageType) { case MessageType.connectionAck: final messageAck = message.payload as ConnectionAckMessagePayload; @@ -432,13 +470,13 @@ class WebSocketConnection implements Closeable { break; } final wsError = message.payload as WebSocketError; - _rebroadcastController.addError(wsError); + rebroadcastController.addError(wsError); return; default: break; } // Re-broadcast other message types related to single subscriptions. - _rebroadcastController.add(message); + rebroadcastController.add(message); } } diff --git a/packages/api/amplify_api/test/dart_graphql_test.dart b/packages/api/amplify_api/test/dart_graphql_test.dart index 46e5ff8eeb..2b5f40ef3a 100644 --- a/packages/api/amplify_api/test/dart_graphql_test.dart +++ b/packages/api/amplify_api/test/dart_graphql_test.dart @@ -123,7 +123,11 @@ class MockAmplifyAPI extends AmplifyAPIDart { @override WebSocketConnection getWebSocketConnection({String? apiName}) => - MockWebSocketConnection(testApiKeyConfig, getTestAuthProviderRepo()); + MockWebSocketConnection( + testApiKeyConfig, + getTestAuthProviderRepo(), + logger: AmplifyLogger(), + ); } void main() { diff --git a/packages/api/amplify_api/test/util.dart b/packages/api/amplify_api/test/util.dart index 4d2d29c757..23bd705a8f 100644 --- a/packages/api/amplify_api/test/util.dart +++ b/packages/api/amplify_api/test/util.dart @@ -19,9 +19,15 @@ 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:async/async.dart'; import 'package:aws_signature_v4/aws_signature_v4.dart'; import 'package:collection/collection.dart'; +import 'package:connectivity_plus_platform_interface/connectivity_plus_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; const testAccessToken = 'test-access-token-123'; @@ -108,6 +114,42 @@ const mockSubscriptionData = { } }; +const mockAckMessage = { + 'type': 'connection_ack', + 'payload': {'connectionTimeoutMs': 300000} +}; + +/// Hub Event Matchers +final connectedHubEvent = isA().having( + (event) => event.status, + 'status', + SubscriptionStatus.connected, +); + +final connectingHubEvent = isA().having( + (event) => event.status, + 'status', + SubscriptionStatus.connecting, +); + +final disconnectedHubEvent = isA().having( + (event) => event.status, + 'status', + SubscriptionStatus.connecting, +); + +final pendingDisconnectedHubEvent = isA().having( + (event) => event.status, + 'status', + SubscriptionStatus.pendingDisconnected, +); + +final failedHubEvent = isA().having( + (event) => event.status, + 'status', + SubscriptionStatus.failed, +); + /// Extension of [WebSocketConnection] that stores messages internally instead /// of sending them. class MockWebSocketConnection extends WebSocketConnection { @@ -120,58 +162,70 @@ class MockWebSocketConnection extends WebSocketConnection { final List sentMessages = []; MockWebSocketConnection( - AWSApiConfig config, AmplifyAuthProviderRepository authProviderRepo) - : super(config, authProviderRepo, const GraphQLSubscriptionOptions(), - logger: AmplifyLogger()); + super.config, + super.authProviderRepo, { + required super.logger, + super.clientOverride, + super.connectivityOverride, + super.subscriptionOptions, + super.webSocketFactoryOverride, + }); WebSocketMessage? get lastSentMessage => sentMessages.lastOrNull; - final messageStream = StreamController(); + /// Pushes message in sentMessages and adds to stream (to support mocking result). + @override + void send(WebSocketMessage message) { + sentMessages.add(message); + super.send(message); + } +} + +// Mock WebSocket + +class MockWebSocketSink extends DelegatingStreamSink implements WebSocketSink { + MockWebSocketSink(super.sink); @override - Future 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 = []; - 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); + Future close([int? closeCode, String? closeReason]) => super.close(); +} + +class MockWebSocketChannel extends WebSocketChannel { + // ignore: close_sinks + final StreamController controller = StreamController.broadcast(); + + static StreamChannel> streamChannel = + StreamChannel(const Stream.empty(), NullStreamSink()); + + MockWebSocketChannel() : super(streamChannel) { + // controller.sink.add(mockAckMessage); } - /// 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); + Stream get stream => StreamView(controller.stream); + + @override + WebSocketSink get sink => MockWebSocketSink(controller.sink); +} + +// Mock Connectivity Plus + +const ConnectivityResult kCheckConnectivityResult = ConnectivityResult.wifi; + +class MockConnectivityPlatform extends Mock + with MockPlatformInterfaceMixin + implements ConnectivityPlatform { + // ignore: close_sinks + final StreamController controller = + StreamController.broadcast(); + + @override + Future checkConnectivity() async { + return kCheckConnectivityResult; + } + + @override + Stream get onConnectivityChanged { + return controller.stream; } } 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 9a1e3e6545..2ab4d07a84 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,18 +15,28 @@ 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_types.dart'; import 'package:amplify_core/amplify_core.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:connectivity_plus_platform_interface/connectivity_plus_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; import '../util.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + late Connectivity connectivity; + late MockConnectivityPlatform fakePlatform; + late MockWebSocketConnection connection; + late StreamController hubEventsController; + late Stream hubEvents; + const graphQLDocument = '''subscription MySubscription { onCreateBlog { id @@ -36,76 +46,165 @@ void main() { }'''; final subscriptionRequest = GraphQLRequest(document: graphQLDocument); - setUp(() { + final startAck = WebSocketMessage( + messageType: MessageType.startAck, + id: subscriptionRequest.id, + ); + + final mockSubscriptionData = { + 'onCreateBlog': { + 'id': '', + 'name': 'Unit Test Blog', + 'createdAt': '2022-09-12T18:03:36.230Z' + } + }; + + final mockSubscriptionEvent = { + 'id': subscriptionRequest.id, + 'type': 'data', + 'payload': { + 'data': mockSubscriptionData, + }, + }; + + WebSocketChannel getWebSocketChannel(Uri uri) { + return MockWebSocketChannel(); + } + + MockWebSocketConnection getWebSocketConnection() { + MockClient mockClient = MockClient((request) async { + return http.Response('healthy', 200); + }); + + fakePlatform = MockConnectivityPlatform(); + ConnectivityPlatform.instance = fakePlatform; + connectivity = Connectivity(); + + GraphQLSubscriptionOptions subscriptionOptions = + const GraphQLSubscriptionOptions(); + connection = MockWebSocketConnection( testApiKeyConfig, getTestAuthProviderRepo(), + subscriptionOptions: subscriptionOptions, + connectivityOverride: connectivity, + clientOverride: mockClient, + webSocketFactoryOverride: getWebSocketChannel, + logger: AmplifyLogger(), ); - }); + + return connection; + } + + Future assertConnected() async { + await expectLater(connection.connectionPending, completes); + + connection.channel!.sink.add(jsonEncode(mockAckMessage)); + + await expectLater(connection.ready, completes); + + connection.channel!.sink.add(jsonEncode(startAck)); + } 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); - expect( - connection.connectedUri.toString(), - expectedApiKeyWebSocketConnectionUrl, - ); - expect( - connection.lastSentMessage?.messageType, MessageType.connectionInit); + setUp(() { + // hubEventsController = StreamController(); + // hubEvents = hubEventsController.stream; + // Amplify.Hub.listen(HubChannel.Api, hubEventsController.add); }); - - test('subscribe() should initialize the connection and call onEstablished', - () async { - connection.subscribe(subscriptionRequest, expectAsync0(() {})); - expectLater(connection.ready, completes); + tearDown(() { + connection.close(); + // Amplify.Hub.close(); + // hubEventsController.close(); }); - test( - 'subscribe() should send SubscriptionRegistrationMessage with authorized payload correctly serialized', - () async { - connection.init(); - await connection.ready; - Completer establishedCompleter = Completer(); - connection.subscribe(subscriptionRequest, () { - establishedCompleter.complete(); - }); - await establishedCompleter.future; - - final lastMessage = connection.lastSentMessage; - expect(lastMessage?.messageType, MessageType.start); - final payloadJson = lastMessage?.payload?.toJson(); - final apiKeyFromPayload = - payloadJson?['extensions']['authorization'][xApiKey]; - expect(apiKeyFromPayload, testApiKeyConfig.apiKey); + test('should init a connection & call onEstablishCallback', () async { + getWebSocketConnection().subscribe( + subscriptionRequest, + expectAsync0(() {}), + ); + + await assertConnected(); + + // expect( + // hubEvents, + // emitsInOrder( + // [ + // emitsThrough(connectingHubEvent), + // emitsThrough(connectedHubEvent), + // ], + // ), + // ); }); test('subscribe() should return a subscription stream', () async { - final subscription = connection.subscribe( + final subscription = getWebSocketConnection().subscribe( subscriptionRequest, - null, + () {}, ); - late StreamSubscription> streamSub; - streamSub = subscription.listen( + await assertConnected(); + + final streamSub = subscription.listen( expectAsync1((event) { expect(event.data, json.encode(mockSubscriptionData)); - streamSub.cancel(); }), ); + + connection.channel!.sink.add(jsonEncode(mockSubscriptionEvent)); + + addTearDown(streamSub.cancel); + }); + + test('should reconnect when network turns on/off', () async { + Completer dataCompleter = Completer(); + final subscription = getWebSocketConnection().subscribe( + subscriptionRequest, + () {}, + ); + + await assertConnected(); + + final streamSub = subscription.listen( + expectAsync1( + (event) => dataCompleter.complete(event.data), + ), + ); + + connection.channel!.sink.add(jsonEncode(mockSubscriptionEvent)); + + fakePlatform.controller.sink.add(ConnectivityResult.none); + + // expect( + // hubEvents, + // emitsInOrder( + // [ + // emitsThrough(connectingHubEvent), + // emitsThrough(connectedHubEvent), + // emitsThrough(connectingHubEvent), + // ], + // ), + // ); + + addTearDown(streamSub.cancel); }); test('cancel() should send a stop message', () async { Completer dataCompleter = Completer(); - final subscription = connection.subscribe(subscriptionRequest, null); + final subscription = + getWebSocketConnection().subscribe(subscriptionRequest, null); + + await assertConnected(); + final streamSub = subscription.listen( (event) => dataCompleter.complete(event.data), ); + + connection.channel!.sink.add(jsonEncode(mockSubscriptionEvent)); await dataCompleter.future; + streamSub.cancel(); - expect(connection.lastSentMessage?.messageType, MessageType.stop); + expect(connection.lastSentMessage!.messageType, MessageType.stop); }); }); } From 426e0fad92e1d1276cc56a0d5ff8d3d352333781 Mon Sep 17 00:00:00 2001 From: equartey Date: Tue, 27 Sep 2022 15:19:36 -0500 Subject: [PATCH 39/47] =?UTF-8?q?Nothing=20gets=20past=20Travis=20?= =?UTF-8?q?=F0=9F=92=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/category/amplify_api_category.dart | 89 +++++++++++++++++-- .../lib/src/category/amplify_categories.dart | 1 - .../lib/src/types/api/rest/http_payload.dart | 82 ----------------- packages/amplify_core/pubspec.yaml | 1 - packages/api/amplify_api/lib/amplify_api.dart | 1 + .../lib/src/amplify_api_config.dart | 75 ---------------- ...web_socket_message_stream_transformer.dart | 6 +- packages/api/amplify_api/lib/src/util.dart | 50 ----------- packages/api/amplify_api/test/util_test.dart | 42 --------- .../test/ws/web_socket_auth_utils_test.dart | 72 +++++++++++++-- 10 files changed, 152 insertions(+), 267 deletions(-) delete mode 100644 packages/amplify_core/lib/src/types/api/rest/http_payload.dart delete mode 100644 packages/api/amplify_api/lib/src/amplify_api_config.dart delete mode 100644 packages/api/amplify_api/lib/src/util.dart delete mode 100644 packages/api/amplify_api/test/util_test.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 1b99a34cae..42080dd7c9 100644 --- a/packages/amplify_core/lib/src/category/amplify_api_category.dart +++ b/packages/amplify_core/lib/src/category/amplify_api_category.dart @@ -50,7 +50,21 @@ class APICategory extends AmplifyCategory { // ====== RestAPI ====== - AWSHttpOperation delete( + /// Sends an HTTP DELETE request to the REST API endpoint. + /// + /// See https://docs.amplify.aws/lib/restapi/update/q/platform/flutter/ for more + /// information. + /// + /// Example: + /// ```dart + /// final restOperation = Amplify.API.delete( + /// 'items', + /// body: HttpPayload.json({'name': 'Mow the lawn'}), + /// ); + /// final response = await restOperation.response; + /// print(response.decodeBody()); // 'Hello from lambda!' + /// ``` + RestOperation delete( String path, { Map? headers, HttpPayload? body, @@ -64,7 +78,19 @@ class APICategory extends AmplifyCategory { apiName: apiName, ); - AWSHttpOperation get( + /// Sends an HTTP GET request to the REST API endpoint. + /// + /// See https://docs.amplify.aws/lib/restapi/fetch/q/platform/flutter/ for more + /// information. + /// + /// Example: + /// + /// ```dart + /// final restOperation = Amplify.API.get('items'); + /// final response = await restOperation.response; + /// print(response.decodeBody()); // 'Hello from lambda!' + /// ``` + RestOperation get( String path, { Map? headers, Map? queryParameters, @@ -76,7 +102,16 @@ class APICategory extends AmplifyCategory { apiName: apiName, ); - AWSHttpOperation head( + /// Sends an HTTP HEAD request to the REST API endpoint. + /// + /// Example: + /// + /// ```dart + /// final restOperation = Amplify.API.head('items'); + /// final response = await restOperation.response; + /// print(response.decodeBody()); // 'Hello from lambda!' + /// ``` + RestOperation head( String path, { Map? headers, Map? queryParameters, @@ -88,7 +123,21 @@ class APICategory extends AmplifyCategory { apiName: apiName, ); - AWSHttpOperation patch( + /// Sends an HTTP PATCH request to the REST API endpoint. + /// + /// See https://docs.amplify.aws/lib/restapi/update/q/platform/flutter/ for more + /// information. + /// + /// Example: + /// ```dart + /// final restOperation = Amplify.API.patch( + /// 'items', + /// body: HttpPayload.json({'name': 'Mow the lawn'}), + /// ); + /// final response = await restOperation.response; + /// print(response.decodeBody()); // 'Hello from lambda!' + /// ``` + RestOperation patch( String path, { Map? headers, HttpPayload? body, @@ -102,7 +151,21 @@ class APICategory extends AmplifyCategory { apiName: apiName, ); - AWSHttpOperation post( + /// Sends an HTTP POST request to the REST API endpoint. + /// + /// See https://docs.amplify.aws/lib/restapi/update/q/platform/flutter/ for more + /// information. + /// + /// Example: + /// ```dart + /// final restOperation = Amplify.API.post( + /// 'items', + /// body: HttpPayload.json({'name': 'Mow the lawn'}), + /// ); + /// final response = await restOperation.response; + /// print(response.decodeBody()); // 'Hello from lambda!' + /// ``` + RestOperation post( String path, { Map? headers, HttpPayload? body, @@ -116,7 +179,21 @@ class APICategory extends AmplifyCategory { apiName: apiName, ); - AWSHttpOperation put( + /// Sends an HTTP PUT request to the REST API endpoint. + /// + /// See https://docs.amplify.aws/lib/restapi/update/q/platform/flutter/ for more + /// information. + /// + /// Example: + /// ```dart + /// final restOperation = Amplify.API.put( + /// 'items', + /// body: HttpPayload.json({'name': 'Mow the lawn'}), + /// ); + /// final response = await restOperation.response; + /// print(response.decodeBody()); // 'Hello from lambda!' + /// ``` + RestOperation put( String path, { Map? headers, HttpPayload? body, diff --git a/packages/amplify_core/lib/src/category/amplify_categories.dart b/packages/amplify_core/lib/src/category/amplify_categories.dart index 4a65b493bb..78c423b2ed 100644 --- a/packages/amplify_core/lib/src/category/amplify_categories.dart +++ b/packages/amplify_core/lib/src/category/amplify_categories.dart @@ -18,7 +18,6 @@ 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/types/api/rest/http_payload.dart b/packages/amplify_core/lib/src/types/api/rest/http_payload.dart deleted file mode 100644 index eb657d7543..0000000000 --- a/packages/amplify_core/lib/src/types/api/rest/http_payload.dart +++ /dev/null @@ -1,82 +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. - * 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> { - 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) { - return HttpPayload.bytes(body); - } - if (body is Stream>) { - return HttpPayload.streaming(body); - } - if (body is Map) { - 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 body) : super(Stream.value(body)); - - /// A form-encoded body of `key=value` pairs. - HttpPayload.formFields(Map 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> 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 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/pubspec.yaml b/packages/amplify_core/pubspec.yaml index 08180499d4..52d30edfd3 100644 --- a/packages/amplify_core/pubspec.yaml +++ b/packages/amplify_core/pubspec.yaml @@ -13,7 +13,6 @@ dependencies: aws_common: ">=0.2.3 <0.3.0" aws_signature_v4: ">=0.2.2 <0.3.0" collection: ^1.15.0 - http: ^0.13.4 intl: ^0.17.0 json_annotation: ^4.6.0 logging: ^1.0.0 diff --git a/packages/api/amplify_api/lib/amplify_api.dart b/packages/api/amplify_api/lib/amplify_api.dart index 1d82f58787..4f02db2ed9 100644 --- a/packages/api/amplify_api/lib/amplify_api.dart +++ b/packages/api/amplify_api/lib/amplify_api.dart @@ -37,6 +37,7 @@ abstract class AmplifyAPI extends APIPluginInterface { }) => AmplifyAPIDart( authProviders: authProviders, + baseHttpClient: baseHttpClient, modelProvider: modelProvider, subscriptionOptions: subscriptionOptions, ); diff --git a/packages/api/amplify_api/lib/src/amplify_api_config.dart b/packages/api/amplify_api/lib/src/amplify_api_config.dart deleted file mode 100644 index 4d4c21e9fa..0000000000 --- a/packages/api/amplify_api/lib/src/amplify_api_config.dart +++ /dev/null @@ -1,75 +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. -// 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 { - const EndpointConfig(this.name, this.config); - - final String name; - final AWSApiConfig config; - - @override - List 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? 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 - 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/graphql/ws/web_socket_message_stream_transformer.dart b/packages/api/amplify_api/lib/src/graphql/ws/web_socket_message_stream_transformer.dart index 04c707df25..f7b181ead2 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 @@ -18,13 +18,11 @@ library amplify_api.graphql.ws.web_socket_message_stream_transformer; import 'dart:async'; import 'dart:convert'; -import 'package:amplify_api/src/util.dart'; +import 'package:amplify_api/src/graphql/graphql_response_decoder.dart'; +import 'package:amplify_api/src/graphql/ws/web_socket_types.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:meta/meta.dart'; -import '../graphql_response_decoder.dart'; -import 'web_socket_types.dart'; - /// Top-level transformer. class WebSocketMessageStreamTransformer extends StreamTransformerBase { diff --git a/packages/api/amplify_api/lib/src/util.dart b/packages/api/amplify_api/lib/src/util.dart deleted file mode 100644 index 2d28b59afc..0000000000 --- a/packages/api/amplify_api/lib/src/util.dart +++ /dev/null @@ -1,50 +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:meta/meta.dart'; - -/// Sets the 'Content-Type' of headers to match the [HttpPayload] body. -@internal -Map? addContentTypeToHeaders( - Map? 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.of(headers ?? {}); - 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? deserializeGraphQLResponseErrors( - Map response, -) { - final errors = response['errors'] as List?; - if (errors == null || errors.isEmpty) { - return null; - } - return errors - .cast() - .map((message) => GraphQLResponseError.fromJson( - message.cast(), - )) - .toList(); -} diff --git a/packages/api/amplify_api/test/util_test.dart b/packages/api/amplify_api/test/util_test.dart deleted file mode 100644 index 28c1d7677c..0000000000 --- a/packages/api/amplify_api/test/util_test.dart +++ /dev/null @@ -1,42 +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. -// 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; charset=utf-8'); - }); - - 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; charset=utf-8'); - expect(inputHeaders['Content-Type'], isNull); - }); - }); -} 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 index c9cfa0c90d..2044b1a767 100644 --- 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 @@ -23,10 +23,15 @@ import '../util.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final authProviderRepo = AmplifyAuthProviderRepository(); - authProviderRepo.registerAuthProvider( + final authProviderRepo = AmplifyAuthProviderRepository() + ..registerAuthProvider( APIAuthorizationType.apiKey.authProviderToken, - AppSyncApiKeyAuthProvider()); + AppSyncApiKeyAuthProvider(), + ) + ..registerAuthProvider( + APIAuthorizationType.userPools.authProviderToken, + TestTokenAuthProvider(), + ); const graphQLDocument = '''subscription MySubscription { onCreateBlog { @@ -37,8 +42,9 @@ void main() { }'''; final subscriptionRequest = GraphQLRequest(document: graphQLDocument); - void _assertBasicSubscriptionPayloadHeaders( - SubscriptionRegistrationPayload payload) { + void assertBasicSubscriptionPayloadHeaders( + SubscriptionRegistrationPayload payload, + ) { expect( payload.authorizationHeaders[AWSHeaders.contentType], 'application/json; charset=utf-8', @@ -75,11 +81,65 @@ void main() { final payload = authorizedMessage.payload as SubscriptionRegistrationPayload; - _assertBasicSubscriptionPayloadHeaders(payload); + assertBasicSubscriptionPayloadHeaders(payload); expect( payload.authorizationHeaders[xApiKey], testApiKeyConfig.apiKey, ); }); + + test('should generate an authorized message with custom authorizationMode', + () async { + final subscriptionRequestUserPools = GraphQLRequest( + document: graphQLDocument, + authorizationMode: APIAuthorizationType.userPools, + ); + + final authorizedMessage = await generateSubscriptionRegistrationMessage( + testApiKeyConfig, + id: 'abc123', + authRepo: authProviderRepo, + request: subscriptionRequestUserPools, + ); + final payload = + authorizedMessage.payload as SubscriptionRegistrationPayload; + + assertBasicSubscriptionPayloadHeaders(payload); + expect( + payload.authorizationHeaders[xApiKey], + isNull, + ); + expect( + payload.authorizationHeaders[AWSHeaders.authorization], + testAccessToken, + ); + }); + + test('should generate an authorized message with custom headers', () async { + const testCustomToken = 'xyz567'; + final subscriptionRequestUserCustomHeader = GraphQLRequest( + document: graphQLDocument, + headers: {AWSHeaders.authorization: testCustomToken}, + ); + + final authorizedMessage = await generateSubscriptionRegistrationMessage( + testApiKeyConfig, + id: 'abc123', + authRepo: authProviderRepo, + request: subscriptionRequestUserCustomHeader, + ); + final payload = + authorizedMessage.payload as SubscriptionRegistrationPayload; + + assertBasicSubscriptionPayloadHeaders(payload); + expect( + payload.authorizationHeaders[xApiKey], + isNull, + ); + expect( + payload.authorizationHeaders[AWSHeaders.authorization], + testCustomToken, + ); + }); }); } From 51e8917f3ca2a4887024ef8496e2b7eadf6f3d16 Mon Sep 17 00:00:00 2001 From: equartey Date: Tue, 27 Sep 2022 15:35:16 -0500 Subject: [PATCH 40/47] Integration file clean up --- .../apiIntegrationTestGraphQL/cli-inputs.json | 11 ---- .../apiIntegrationTestGraphQL/parameters.json | 5 -- .../resolvers/README.md | 2 - .../apiIntegrationTestGraphQL/schema.graphql | 1 - .../stacks/CustomResources.json | 58 ------------------- .../transform.conf.json | 4 -- 6 files changed, 81 deletions(-) delete mode 100644 packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/cli-inputs.json delete mode 100644 packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/parameters.json delete mode 100644 packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/resolvers/README.md delete mode 100644 packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/schema.graphql delete mode 100644 packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/stacks/CustomResources.json delete mode 100644 packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/transform.conf.json diff --git a/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/cli-inputs.json b/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/cli-inputs.json deleted file mode 100644 index 6729246746..0000000000 --- a/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/cli-inputs.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": 1, - "serviceConfiguration": { - "apiName": "apiIntegrationTestGraphQL", - "serviceName": "AppSync", - "defaultAuthType": { - "mode": "API_KEY", - "expirationTime": 365 - } - } -} \ No newline at end of file diff --git a/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/parameters.json b/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/parameters.json deleted file mode 100644 index 5dc0d67121..0000000000 --- a/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/parameters.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "AppSyncApiName": "apiIntegrationTestGraphQL", - "DynamoDBBillingMode": "PAY_PER_REQUEST", - "DynamoDBEnableServerSideEncryption": false -} \ No newline at end of file diff --git a/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/resolvers/README.md b/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/resolvers/README.md deleted file mode 100644 index 89e564c5b3..0000000000 --- a/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/resolvers/README.md +++ /dev/null @@ -1,2 +0,0 @@ -Any resolvers that you add in this directory will override the ones automatically generated by Amplify CLI and will be directly copied to the cloud. -For more information, visit [https://docs.amplify.aws/cli/graphql-transformer/resolvers](https://docs.amplify.aws/cli/graphql-transformer/resolvers) \ No newline at end of file diff --git a/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/schema.graphql b/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/schema.graphql deleted file mode 100644 index aa8300d52b..0000000000 --- a/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/schema.graphql +++ /dev/null @@ -1 +0,0 @@ -input AMPLIFY { globalAuthRule: AuthRule = { allow: public }}type FileMeta { name: String!}type S3Object { bucket: String! region: String! key: String! meta: FileMeta}type Blog @model { id: ID! name: String! createdAt: AWSDateTime file: S3Object files: [S3Object] posts: [Post] @hasMany(indexName: "byBlog", fields: ["id"])}type Post @model { id: ID! title: String! rating: Int! created: AWSDateTime blogID: ID! @index(name: "byBlog") blog: Blog @belongsTo(fields: ["blogID"]) comments: [Comment] @hasMany(indexName: "byPost", fields: ["id"])}type Comment @model { id: ID! postID: ID! @index(name: "byPost") post: Post @belongsTo(fields: ["postID"]) content: String!} \ No newline at end of file diff --git a/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/stacks/CustomResources.json b/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/stacks/CustomResources.json deleted file mode 100644 index f95feea378..0000000000 --- a/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/stacks/CustomResources.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "AWSTemplateFormatVersion": "2010-09-09", - "Description": "An auto-generated nested stack.", - "Metadata": {}, - "Parameters": { - "AppSyncApiId": { - "Type": "String", - "Description": "The id of the AppSync API associated with this project." - }, - "AppSyncApiName": { - "Type": "String", - "Description": "The name of the AppSync API", - "Default": "AppSyncSimpleTransform" - }, - "env": { - "Type": "String", - "Description": "The environment name. e.g. Dev, Test, or Production", - "Default": "NONE" - }, - "S3DeploymentBucket": { - "Type": "String", - "Description": "The S3 bucket containing all deployment assets for the project." - }, - "S3DeploymentRootKey": { - "Type": "String", - "Description": "An S3 key relative to the S3DeploymentBucket that points to the root\nof the deployment directory." - } - }, - "Resources": { - "EmptyResource": { - "Type": "Custom::EmptyResource", - "Condition": "AlwaysFalse" - } - }, - "Conditions": { - "HasEnvironmentParameter": { - "Fn::Not": [ - { - "Fn::Equals": [ - { - "Ref": "env" - }, - "NONE" - ] - } - ] - }, - "AlwaysFalse": { - "Fn::Equals": ["true", "false"] - } - }, - "Outputs": { - "EmptyOutput": { - "Description": "An empty output. You may delete this if you have at least one resource above.", - "Value": "" - } - } -} diff --git a/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/transform.conf.json b/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/transform.conf.json deleted file mode 100644 index 98e1e19f03..0000000000 --- a/packages/api/amplify_api/example/amplify/backend/api/apiIntegrationTestGraphQL/transform.conf.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "Version": 5, - "ElasticsearchWarning": true -} \ No newline at end of file From 4aab1e1ee82fdebc35ca6bb81ee120f64ecc69b2 Mon Sep 17 00:00:00 2001 From: equartey Date: Tue, 27 Sep 2022 15:36:59 -0500 Subject: [PATCH 41/47] missed two files --- .../example/lib/graphql_api_view.dart | 1 - .../amplify_authorization_rest_client.dart | 52 ------------------- 2 files changed, 53 deletions(-) delete mode 100644 packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart 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 19515d716f..59c6d51511 100644 --- a/packages/api/amplify_api/example/lib/graphql_api_view.dart +++ b/packages/api/amplify_api/example/lib/graphql_api_view.dart @@ -16,7 +16,6 @@ import 'dart:async'; import 'package:amplify_flutter/amplify_flutter.dart'; -import 'package:async/async.dart'; import 'package:flutter/material.dart'; class GraphQLApiView extends StatefulWidget { 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 deleted file mode 100644 index db928d0dee..0000000000 --- a/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart +++ /dev/null @@ -1,52 +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 'dart:async'; - -import 'package:amplify_api/src/decorators/authorize_http_request.dart'; -import 'package:amplify_core/amplify_core.dart'; -import 'package:meta/meta.dart'; - -/// Implementation of http [http.Client] that authorizes HTTP requests with -/// Amplify. -@internal -class AmplifyAuthorizationRestClient extends AWSBaseHttpClient { - /// [AmplifyAuthProviderRepository] for any auth modes this client may use. - final AmplifyAuthProviderRepository authProviderRepo; - - /// Determines how requests with this client are authorized. - final AWSApiConfig endpointConfig; - - /// Provide an [AWSApiConfig] which will determine how requests from this - /// client are authorized. - AmplifyAuthorizationRestClient({ - required this.endpointConfig, - required this.authProviderRepo, - AWSHttpClient? baseClient, - }) : baseClient = baseClient ?? AWSHttpClient(); - - @override - final AWSHttpClient baseClient; - - /// Implementation of [send] that authorizes any request without "Authorization" - /// header already set. - @override - Future transformRequest(AWSBaseHttpRequest request) { - return authorizeHttpRequest( - request, - endpointConfig: endpointConfig, - authProviderRepo: authProviderRepo, - ); - } -} From b8677e961a22fe2e5f05cf4ed53b0d976990c691 Mon Sep 17 00:00:00 2001 From: equartey Date: Tue, 27 Sep 2022 16:16:16 -0500 Subject: [PATCH 42/47] Removed http dep --- .../lib/src/graphql/ws/web_socket_connection.dart | 13 +++++++------ packages/api/amplify_api/pubspec.yaml | 1 - .../test/ws/web_socket_connection_test.dart | 13 +++++++------ 3 files changed, 14 insertions(+), 13 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 2c75539680..268dea4456 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 @@ -21,7 +21,6 @@ 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:connectivity_plus/connectivity_plus.dart'; -import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:web_socket_channel/status.dart' as status; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -62,7 +61,7 @@ class WebSocketConnection implements Closeable { StreamController.broadcast(); RestartableTimer? _timeoutTimer; - late http.Client _pingClient; + late AWSHttpClient _pingClient; Timer? _pingTimer; late Uri _pingUri; bool _hasNetwork = false; @@ -113,12 +112,12 @@ class WebSocketConnection implements Closeable { required AmplifyLogger logger, GraphQLSubscriptionOptions? subscriptionOptions, @visibleForTesting WebSocketFactory? webSocketFactoryOverride, - @visibleForTesting http.Client? clientOverride, + @visibleForTesting AWSHttpClient? clientOverride, @visibleForTesting Connectivity? connectivityOverride, }) { _logger = logger; _subscriptionOptions = subscriptionOptions; - _pingClient = clientOverride ?? http.Client(); + _pingClient = clientOverride ?? AWSHttpClient(); _connectivityOverride = connectivityOverride; _webSocketFactoryOverride = webSocketFactoryOverride; _pingUri = Uri.parse(_config.endpoint).replace(path: 'ping'); @@ -293,8 +292,10 @@ class WebSocketConnection implements Closeable { } /// [GET] request on the configured AppSync url via the `/ping` endpoint - Future _getPollRequest() { - return _pingClient.get(_pingUri); + Future _getPollRequest() { + return AWSHttpRequest.get( + _pingUri, + ).send(_pingClient).response; } /// Clear variables used to track subscriptions and other helper variables diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index a683f8afad..834a821cdc 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -21,7 +21,6 @@ dependencies: connectivity_plus: ^2.3.6 flutter: sdk: flutter - http: ^0.13.4 json_annotation: ^4.6.0 meta: ^1.7.0 plugin_platform_interface: ^2.0.0 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 2ab4d07a84..3064dacd2c 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,11 +17,10 @@ import 'dart:convert'; import 'package:amplify_api/src/graphql/ws/web_socket_types.dart'; import 'package:amplify_core/amplify_core.dart'; +import 'package:aws_common/testing.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus_platform_interface/connectivity_plus_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../util.dart'; @@ -72,16 +71,18 @@ void main() { } MockWebSocketConnection getWebSocketConnection() { - MockClient mockClient = MockClient((request) async { - return http.Response('healthy', 200); + final mockClient = MockAWSHttpClient((request) async { + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode('healthy'), + ); }); fakePlatform = MockConnectivityPlatform(); ConnectivityPlatform.instance = fakePlatform; connectivity = Connectivity(); - GraphQLSubscriptionOptions subscriptionOptions = - const GraphQLSubscriptionOptions(); + const subscriptionOptions = GraphQLSubscriptionOptions(); connection = MockWebSocketConnection( testApiKeyConfig, From 3264fc7752f6b593f880c315340b5a1e9906f560 Mon Sep 17 00:00:00 2001 From: equartey Date: Wed, 28 Sep 2022 14:44:43 -0500 Subject: [PATCH 43/47] Types, Lints, overrides, and Passing Tests. --- .../lib/src/hub/amplify_hub_impl.dart | 4 + .../api/amplify_api/example/amplify/README.md | 8 - .../example/lib/graphql_api_view.dart | 2 - .../example/lib/rest_api_view.dart | 1 - .../amplify_api/lib/src/api_plugin_impl.dart | 29 +-- .../src/graphql/ws/web_socket_connection.dart | 207 ++++++++++++------ ...web_socket_message_stream_transformer.dart | 20 +- .../lib/src/graphql/ws/web_socket_types.dart | 50 ++--- .../amplify_api/test/dart_graphql_test.dart | 125 ++++++++--- packages/api/amplify_api/test/util.dart | 59 +++-- .../test/ws/web_socket_connection_test.dart | 155 ++++++------- 11 files changed, 387 insertions(+), 273 deletions(-) delete mode 100644 packages/api/amplify_api/example/amplify/README.md diff --git a/packages/amplify_core/lib/src/hub/amplify_hub_impl.dart b/packages/amplify_core/lib/src/hub/amplify_hub_impl.dart index d663b84ade..abe6e4b546 100644 --- a/packages/amplify_core/lib/src/hub/amplify_hub_impl.dart +++ b/packages/amplify_core/lib/src/hub/amplify_hub_impl.dart @@ -85,6 +85,10 @@ class AmplifyHubImpl extends AmplifyHub { Future.wait([ for (final stream in _availableStreams.values) stream.close(), ]).ignore(); + Future.wait([ + for (final subs in _subscriptions.values) + for (final sub in subs) sub.cancel() + ]).ignore(); _availableStreams.clear(); _subscriptions.clear(); } diff --git a/packages/api/amplify_api/example/amplify/README.md b/packages/api/amplify_api/example/amplify/README.md deleted file mode 100644 index 7c0a9e285f..0000000000 --- a/packages/api/amplify_api/example/amplify/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Getting Started with Amplify CLI -This directory was generated by [Amplify CLI](https://docs.amplify.aws/cli). - -Helpful resources: -- Amplify documentation: https://docs.amplify.aws -- Amplify CLI documentation: https://docs.amplify.aws/cli -- More details on this folder & generated files: https://docs.amplify.aws/cli/reference/files -- Join Amplify's community: https://amplify.aws/community/ 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 59c6d51511..b0c87f365c 100644 --- a/packages/api/amplify_api/example/lib/graphql_api_view.dart +++ b/packages/api/amplify_api/example/lib/graphql_api_view.dart @@ -13,8 +13,6 @@ * permissions and limitations under the License. */ -import 'dart:async'; - import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:flutter/material.dart'; 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 39bfc5ea6e..f6ef2f720b 100644 --- a/packages/api/amplify_api/example/lib/rest_api_view.dart +++ b/packages/api/amplify_api/example/lib/rest_api_view.dart @@ -14,7 +14,6 @@ */ import 'package:amplify_flutter/amplify_flutter.dart'; -import 'package:async/async.dart'; import 'package:flutter/material.dart'; class RestApiView extends StatefulWidget { 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 b856a2a427..168a3d1f3f 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -32,6 +32,17 @@ import 'package:meta/meta.dart'; /// The AWS implementation of the Amplify API category. /// {@endtemplate} class AmplifyAPIDart extends AmplifyAPI { + /// {@macro amplify_api.amplify_api_dart} + AmplifyAPIDart({ + List authProviders = const [], + AWSHttpClient? baseHttpClient, + this.modelProvider, + this.subscriptionOptions, + }) : _baseHttpClient = baseHttpClient, + super.protected() { + authProviders.forEach(registerAuthProvider); + } + late final AWSApiPluginConfig _apiConfig; final AWSHttpClient? _baseHttpClient; late final AmplifyAuthProviderRepository _authProviderRepo; @@ -51,17 +62,6 @@ class AmplifyAPIDart extends AmplifyAPI { /// Subscription options final GraphQLSubscriptionOptions? subscriptionOptions; - /// {@macro amplify_api.amplify_api_dart} - AmplifyAPIDart({ - List authProviders = const [], - AWSHttpClient? baseHttpClient, - this.modelProvider, - this.subscriptionOptions, - }) : _baseHttpClient = baseHttpClient, - super.protected() { - authProviders.forEach(registerAuthProvider); - } - @override Future configure({ AmplifyConfig? config, @@ -126,9 +126,10 @@ class AmplifyAPIDart extends AmplifyAPI { if (e.code == 'AmplifyAlreadyConfiguredException' || e.code == 'AlreadyConfiguredException') { throw const AmplifyAlreadyConfiguredException( - AmplifyExceptionMessages.alreadyConfiguredDefaultMessage, - recoverySuggestion: - AmplifyExceptionMessages.alreadyConfiguredDefaultSuggestion); + AmplifyExceptionMessages.alreadyConfiguredDefaultMessage, + recoverySuggestion: + AmplifyExceptionMessages.alreadyConfiguredDefaultSuggestion, + ); } throw AmplifyException.fromMap((e.details as Map).cast()); } 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 268dea4456..3e5e1a28ea 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 @@ -13,11 +13,12 @@ // limitations under the License. import 'dart:async'; - import 'dart:collection'; import 'dart:convert'; import 'package:amplify_api/src/decorators/web_socket_auth_utils.dart'; +import 'package:amplify_api/src/graphql/ws/web_socket_message_stream_transformer.dart'; +import 'package:amplify_api/src/graphql/ws/web_socket_types.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:async/async.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; @@ -25,14 +26,13 @@ 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; const Duration _defaultPingInterval = Duration(seconds: 30); const Duration _defaultRetryTimeout = Duration(seconds: 5); +/// Exposed WebSocketChannel hook used in testing. +@visibleForTesting typedef WebSocketFactory = WebSocketChannel Function(Uri connectionUri); /// {@template amplify_api.ws.web_socket_connection} @@ -40,6 +40,21 @@ typedef WebSocketFactory = WebSocketChannel Function(Uri connectionUri); /// {@endtemplate} @internal class WebSocketConnection implements Closeable { + /// {@macro amplify_api.ws.web_socket_connection} + WebSocketConnection( + this._config, + this._authProviderRepo, { + required AmplifyLogger logger, + GraphQLSubscriptionOptions? subscriptionOptions, + }) { + _logger = logger; + _subscriptionOptions = subscriptionOptions; + _pingUri = Uri.parse(_config.endpoint).replace(path: 'ping'); + + // Establish HubEvents Stream + Amplify.Hub.addChannel(HubChannel.Api, _hubEventsController.stream); + } + /// Allowed protocols for this connection. static const webSocketProtocols = ['graphql-ws']; late final AmplifyLogger _logger; @@ -49,11 +64,20 @@ class WebSocketConnection implements Closeable { 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. + /// 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. + WebSocketChannel? _channel; + + /// Expose Channel for testing @visibleForTesting - WebSocketChannel? channel; + WebSocketChannel? get channel => _channel; + + @visibleForTesting + set stateMachine(WebSocketChannel? channel) { + _isTesting(); + _channel = channel; + } StreamSubscription? _subscription; StreamSubscription? _network; @@ -61,7 +85,7 @@ class WebSocketConnection implements Closeable { StreamController.broadcast(); RestartableTimer? _timeoutTimer; - late AWSHttpClient _pingClient; + late AWSHttpClient? _pingClient; Timer? _pingTimer; late Uri _pingUri; bool _hasNetwork = false; @@ -73,12 +97,55 @@ class WebSocketConnection implements Closeable { /// Subscription options late final GraphQLSubscriptionOptions? _subscriptionOptions; - final Duration _defaultPingInterval = const Duration(seconds: 30); - final Duration _defaultRetryTimeout = const Duration(seconds: 5); - // Overrides - late final Connectivity? _connectivityOverride; - late final WebSocketFactory? _webSocketFactoryOverride; + static void _isTesting() { + if (!zAssertsEnabled) throw StateError('Can only be called in tests'); + } + + /// Overrides + static AWSHttpClient? _httpClientOverride; + static Connectivity? _connectivityOverride; + static WebSocketFactory? _webSocketFactoryOverride; + + /// The underlying connectivity object getter, for use in testing. + @visibleForTesting + static AWSHttpClient? get httpClientOverride => _httpClientOverride; + + /// The underlying connectivity object setter, for use in testing. + @visibleForTesting + static set httpClientOverride( + AWSHttpClient? httpClientOverride, + ) { + _isTesting(); + _httpClientOverride = httpClientOverride; + } + + /// The underlying connectivity object getter, for use in testing. + @visibleForTesting + static Connectivity? get connectivityOverride => _connectivityOverride; + + /// The underlying connectivity object setter, for use in testing. + @visibleForTesting + static set connectivityOverride( + Connectivity? connectivityOverride, + ) { + _isTesting(); + _connectivityOverride = connectivityOverride; + } + + /// The underlying web socket getter, for use in testing. + @visibleForTesting + static WebSocketFactory? get webSocketFactoryOverride => + _webSocketFactoryOverride; + + /// The underlying web socket setter, for use in testing. + @visibleForTesting + static set webSocketFactoryOverride( + WebSocketFactory? webSocketFactoryOverride, + ) { + _isTesting(); + _webSocketFactoryOverride = webSocketFactoryOverride; + } /// Re-broadcasts incoming messages for child streams (single GraphQL subscriptions). /// start_ack, data, error @@ -105,31 +172,11 @@ class WebSocketConnection implements Closeable { @visibleForTesting Future get connectionStart => _connectionStart.future; - /// {@macro amplify_api.ws.web_socket_connection} - WebSocketConnection( - this._config, - this._authProviderRepo, { - required AmplifyLogger logger, - GraphQLSubscriptionOptions? subscriptionOptions, - @visibleForTesting WebSocketFactory? webSocketFactoryOverride, - @visibleForTesting AWSHttpClient? clientOverride, - @visibleForTesting Connectivity? connectivityOverride, - }) { - _logger = logger; - _subscriptionOptions = subscriptionOptions; - _pingClient = clientOverride ?? AWSHttpClient(); - _connectivityOverride = connectivityOverride; - _webSocketFactoryOverride = webSocketFactoryOverride; - _pingUri = Uri.parse(_config.endpoint).replace(path: 'ping'); - - // Establish HubEvents Stream - Amplify.Hub.addChannel(HubChannel.Api, _hubEventsController.stream); - } - /// Connects _subscription stream to _onData handler. @visibleForTesting StreamSubscription getStreamSubscription( - Stream stream) { + Stream stream, + ) { return stream.transform(const WebSocketMessageStreamTransformer()).listen( _onData, onDone: () { @@ -140,7 +187,7 @@ class WebSocketConnection implements Closeable { }, onError: (Object e, StackTrace st) { if (_hasNetwork && _subscriptionRequests.isNotEmpty) { - _retry(); + _reEstablishConnection(); } else { _hubEventsController.add(SubscriptionHubEvent.failed()); _connectionError( @@ -168,7 +215,7 @@ class WebSocketConnection implements Closeable { case ConnectivityResult.wifi: _hasNetwork = true; // only reconnect if connection was lost - if (_hasConnectionBroken) _retry(); + if (_hasConnectionBroken) await _reEstablishConnection(); break; case ConnectivityResult.none: _hasNetwork = false; @@ -181,16 +228,16 @@ class WebSocketConnection implements Closeable { }); } - /// Connects WebSocket channel to _subscription stream but does not send connection + /// Connects WebSocket _channel to _subscription stream but does not send connection /// init message. @visibleForTesting Future connect(Uri connectionUri) async { - channel = _webSocketFactoryOverride?.call(connectionUri) ?? + _channel = _webSocketFactoryOverride?.call(connectionUri) ?? WebSocketChannel.connect( connectionUri, protocols: webSocketProtocols, ); - _subscription = getStreamSubscription(channel!.stream); + _subscription = getStreamSubscription(_channel!.stream); _network = getStreamNetwork(); } @@ -198,7 +245,7 @@ class WebSocketConnection implements Closeable { if (!_connectionReady.isCompleted) { _connectionReady.completeError(exception); } - channel?.sink.close(); + _channel?.sink.close(); _resetConnectionInit(); _resetConnectionVariables(); } @@ -219,7 +266,7 @@ class WebSocketConnection implements Closeable { _resetConnectionVariables(closeStatus); _subscriptionRequests.clear(); - _pingClient.close(); + _pingClient?.close(); } /// Initiate polling @@ -236,7 +283,8 @@ class WebSocketConnection implements Closeable { Future _poll() async { try { final res = await _getPollRequest(); - if (res.body != 'healthy') { + final body = await utf8.decodeStream(res.body); + if (body != 'healthy') { throw ApiException( 'Subscription connection broken. AppSync status check returned: ${res.body}', recoverySuggestion: @@ -255,7 +303,7 @@ class WebSocketConnection implements Closeable { // only try to recover if network is turned on if (_hasNetwork) { - _retry(); + await _reEstablishConnection(); } else { _hubEventsController.add(SubscriptionHubEvent.connecting()); } @@ -264,8 +312,8 @@ class WebSocketConnection implements Closeable { /// Attempts to connect to the configured graphql endpoint. /// Upon successful ping, a new websocket connection is initialized. - Future _retry() async { - RetryOptions retryStrategy = + Future _reEstablishConnection() async { + final retryStrategy = _subscriptionOptions?.retryOptions ?? const RetryOptions(); try { @@ -273,25 +321,27 @@ class WebSocketConnection implements Closeable { _hubEventsController.add(SubscriptionHubEvent.connecting()); await retryStrategy.retry( - // Make a GET request - () => _getPollRequest().timeout(_defaultRetryTimeout)); + // Make a GET request + () => _getPollRequest().timeout(_defaultRetryTimeout), + ); // we can reach AppSync, precede with reconnect - _init(); + await _init(); } on Exception catch (e) { // no network, can't reconnect _hubEventsController.add(SubscriptionHubEvent.disconnected()); close(); _connectionError( ApiException( - 'Unable to recover network connection, web socket will close.', - recoverySuggestion: 'Check internet connection.', - underlyingException: e), + 'Unable to recover network connection, web socket will close.', + recoverySuggestion: 'Check internet connection.', + underlyingException: e, + ), ); } } - /// [GET] request on the configured AppSync url via the `/ping` endpoint + /// GET request on the configured AppSync url via the `/ping` endpoint Future _getPollRequest() { return AWSHttpRequest.get( _pingUri, @@ -304,17 +354,19 @@ class WebSocketConnection implements Closeable { final reason = closeStatus == _defaultCloseStatus ? 'client closed' : 'unknown'; - channel?.sink.done.whenComplete(() { + _channel?.sink.done.whenComplete(() { _network?.cancel(); _subscription?.cancel(); _timeoutTimer?.cancel(); + _pingTimer?.cancel(); - channel = null; + _channel = null; _network = null; _subscription = null; _timeoutTimer = null; + _pingTimer = null; }); - channel?.sink.close(closeStatus, reason); + _channel?.sink.close(closeStatus, reason); } /// Initializes the connection. @@ -342,14 +394,20 @@ class WebSocketConnection implements Closeable { await ready; + _pingClient ??= httpClientOverride ?? AWSHttpClient(); + // connection ready, start polling _startPolling(); // resubscribe to previous subscriptions - for (var req in _subscriptionRequests) { + for (final req in _subscriptionRequests) { final subscriptionRegistrationMessage = - await generateSubscriptionRegistrationMessage(_config, - id: req.id, authRepo: _authProviderRepo, request: req); + await generateSubscriptionRegistrationMessage( + _config, + id: req.id, + authRepo: _authProviderRepo, + request: req, + ); send(subscriptionRegistrationMessage); } @@ -357,7 +415,8 @@ class WebSocketConnection implements Closeable { } Future _sendSubscriptionRegistrationMessage( - GraphQLRequest request) async { + GraphQLRequest request, + ) async { await init(); // no-op if already connected final subscriptionRegistrationMessage = await generateSubscriptionRegistrationMessage( @@ -389,11 +448,13 @@ class WebSocketConnection implements Closeable { // stream with messages converted to GraphQLResponse. _messageStream .where((msg) => msg.id == request.id) - .transform(WebSocketSubscriptionStreamTransformer( - request, - onEstablished, - logger: _logger, - )) + .transform( + WebSocketSubscriptionStreamTransformer( + request, + onEstablished, + logger: _logger, + ), + ) .listen( controller.add, onError: controller.addError, @@ -417,11 +478,11 @@ class WebSocketConnection implements Closeable { // if (_subscriptionRequests.isEmpty) close(status.normalClosure); } - /// Serializes a message as JSON string and sends over WebSocket channel. + /// Serializes a message as JSON string and sends over WebSocket _channel. @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). @@ -442,8 +503,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: ${prettyPrintJson(message)}'); - print('onData: $message'); + _logger.verbose('websocket received message: ${prettyPrintJson(message)}'); switch (message.messageType) { case MessageType.connectionAck: final messageAck = message.payload as ConnectionAckMessagePayload; @@ -458,8 +518,11 @@ class WebSocketConnection implements Closeable { _logger.verbose('Connection established. Registered timer'); return; case MessageType.connectionError: - _connectionError(const ApiException( - 'Error occurred while connecting to the websocket')); + _connectionError( + const ApiException( + 'Error occurred while connecting to the websocket', + ), + ); return; case MessageType.keepAlive: _timeoutTimer?.reset(); 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 f7b181ead2..67889c7b21 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 @@ -42,6 +42,15 @@ class WebSocketMessageStreamTransformer /// of `GraphQLResponse` that is eventually passed to public API `Amplify.API.subscribe`. class WebSocketSubscriptionStreamTransformer extends StreamTransformerBase> { + /// [request] is used to properly decode response events + /// [onEstablished] is executed when start_ack message received + /// [logger] logs cancel messages when complete message received + WebSocketSubscriptionStreamTransformer( + this.request, + this.onEstablished, { + required this.logger, + }); + /// request for this stream, needed to properly decode response events final GraphQLRequest request; @@ -52,18 +61,9 @@ class WebSocketSubscriptionStreamTransformer final void Function()? onEstablished; bool _establishedRequest = false; - /// [request] is used to properly decode response events - /// [onEstablished] is executed when start_ack message received - /// [logger] logs cancel messages when complete message received - WebSocketSubscriptionStreamTransformer( - this.request, - this.onEstablished, { - required this.logger, - }); - @override Stream> bind(Stream stream) async* { - await for (var event in stream) { + await for (final event in stream) { switch (event.messageType) { case MessageType.startAck: if (!_establishedRequest) { 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 c957b82641..97d81dc40d 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 @@ -54,27 +54,30 @@ enum MessageType { @JsonValue('complete') complete('complete'); - final String type; - const MessageType(this.type); factory MessageType.fromJson(dynamic json) { return MessageType.values.firstWhere((el) => json == el.type); } + + final String type; } @immutable abstract class WebSocketMessagePayload { const WebSocketMessagePayload(); - static const Map - _factories = { + static const Map)> _factories = { MessageType.connectionAck: ConnectionAckMessagePayload.fromJson, MessageType.data: SubscriptionDataPayload.fromJson, MessageType.error: WebSocketError.fromJson, }; - static WebSocketMessagePayload? fromJson(Map json, MessageType type) { + static WebSocketMessagePayload? fromJson( + Map json, + MessageType type, + ) { return _factories[type]?.call(json); } @@ -86,11 +89,10 @@ abstract class WebSocketMessagePayload { @internal class ConnectionAckMessagePayload extends WebSocketMessagePayload { - final int connectionTimeoutMs; - const ConnectionAckMessagePayload(this.connectionTimeoutMs); + final int connectionTimeoutMs; - static ConnectionAckMessagePayload fromJson(Map json) { + static ConnectionAckMessagePayload fromJson(Map json) { final connectionTimeoutMs = json['connectionTimeoutMs'] as int; return ConnectionAckMessagePayload(connectionTimeoutMs); } @@ -102,21 +104,21 @@ class ConnectionAckMessagePayload extends WebSocketMessagePayload { } class SubscriptionRegistrationPayload extends WebSocketMessagePayload { - final GraphQLRequest request; - final AWSApiConfig config; - final Map authorizationHeaders; - const SubscriptionRegistrationPayload({ required this.request, required this.config, required this.authorizationHeaders, }); + final GraphQLRequest request; + final AWSApiConfig config; + final Map authorizationHeaders; @override Map toJson() { return { 'data': jsonEncode( - {'variables': request.variables, 'query': request.document}), + {'variables': request.variables, 'query': request.document}, + ), 'extensions': >{ 'authorization': authorizationHeaders } @@ -125,12 +127,11 @@ class SubscriptionRegistrationPayload extends WebSocketMessagePayload { } class SubscriptionDataPayload extends WebSocketMessagePayload { + const SubscriptionDataPayload(this.data, this.errors); final Map? data; final Map? errors; - const SubscriptionDataPayload(this.data, this.errors); - - static SubscriptionDataPayload fromJson(Map json) { + static SubscriptionDataPayload fromJson(Map json) { final data = json['data'] as Map?; final errors = json['errors'] as Map?; return SubscriptionDataPayload( @@ -147,11 +148,10 @@ class SubscriptionDataPayload extends WebSocketMessagePayload { } class WebSocketError extends WebSocketMessagePayload implements Exception { - final List errors; - const WebSocketError(this.errors); + final List> errors; - static WebSocketError fromJson(Map json) { + static WebSocketError fromJson(Map json) { final errors = json['errors'] as List?; return WebSocketError(errors?.cast() ?? []); } @@ -164,27 +164,25 @@ class WebSocketError extends WebSocketMessagePayload implements Exception { @immutable class WebSocketMessage { - final String? id; - final MessageType messageType; - final WebSocketMessagePayload? payload; - WebSocketMessage({ String? id, required this.messageType, this.payload, }) : id = id ?? uuid(); - const WebSocketMessage._({ this.id, required this.messageType, this.payload, }); + final String? id; + final MessageType messageType; + final WebSocketMessagePayload? payload; - static WebSocketMessage fromJson(Map json) { + 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 payloadMap = json['payload'] as Map?; final payload = payloadMap == null ? null : WebSocketMessagePayload.fromJson( diff --git a/packages/api/amplify_api/test/dart_graphql_test.dart b/packages/api/amplify_api/test/dart_graphql_test.dart index 0ad7b17f53..54f8183fe4 100644 --- a/packages/api/amplify_api/test/dart_graphql_test.dart +++ b/packages/api/amplify_api/test/dart_graphql_test.dart @@ -23,6 +23,8 @@ import 'package:amplify_core/amplify_core.dart'; import 'package:amplify_test/test_models/ModelProvider.dart'; import 'package:aws_common/testing.dart'; import 'package:collection/collection.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:connectivity_plus_platform_interface/connectivity_plus_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; import 'test_data/fake_amplify_configuration.dart'; @@ -99,6 +101,12 @@ const _expectedAuthErrorResponseBody = { }; final mockHttpClient = MockAWSHttpClient((request) async { + if (request.uri.path == '/ping') { + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode('healthy'), + ); + } if (request.headers[xApiKey] != 'abc123' && request.headers[AWSHeaders.authorization] == testFunctionToken) { // Not authorized w API key but has lambda auth token, mock that auth mode @@ -138,6 +146,12 @@ final mockHttpClient = MockAWSHttpClient((request) async { ); }); +final mockWebSocketConnection = MockWebSocketConnection( + testApiKeyConfig, + getTestAuthProviderRepo(), + logger: AmplifyLogger(), +); + class MockAmplifyAPI extends AmplifyAPIDart { MockAmplifyAPI({ super.authProviders, @@ -146,12 +160,9 @@ class MockAmplifyAPI extends AmplifyAPIDart { }); @override - WebSocketConnection getWebSocketConnection({String? apiName}) => - MockWebSocketConnection( - testApiKeyConfig, - getTestAuthProviderRepo(), - logger: AmplifyLogger(), - ); + WebSocketConnection getWebSocketConnection({String? apiName}) { + return mockWebSocketConnection; + } } void main() { @@ -236,33 +247,6 @@ void main() { expect(res.data, equals(expected)); expect(res.errors, equals(null)); }); - - test('subscribe() should return a subscription stream', () async { - final establishedCompleter = Completer(); - final dataCompleter = Completer(); - const graphQLDocument = '''subscription MySubscription { - onCreateBlog { - id - name - createdAt - } - }'''; - final subscriptionRequest = - GraphQLRequest(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)); - await streamSub.cancel(); - }); }); group('Model Helpers', () { const blogSelectionSet = @@ -288,6 +272,22 @@ void main() { expect(res.data?.id, _modelQueryId); expect(res.errors, equals(null)); }); + }); + + group('Subscriptions', () { + setUp(() { + final fakePlatform = MockConnectivityPlatform(); + ConnectivityPlatform.instance = fakePlatform; + final connectivity = Connectivity(); + + WebSocketConnection.httpClientOverride = mockHttpClient; + WebSocketConnection.connectivityOverride = connectivity; + WebSocketConnection.webSocketFactoryOverride = getMockWebSocketChannel; + }); + + tearDown(() async { + mockWebSocketConnection.close(); + }); test('subscribe() should decode model data', () async { final establishedCompleter = Completer(); @@ -296,15 +296,72 @@ void main() { subscriptionRequest, onEstablished: establishedCompleter.complete, ); + await assertWebSocketConnected( + mockWebSocketConnection, + subscriptionRequest.id, + ); + await establishedCompleter.future; late StreamSubscription> streamSub; streamSub = subscription.listen( expectAsync1((event) { expect(event.data, isA()); - streamSub.cancel(); }), ); + + final mockSubscriptionEvent = { + 'id': subscriptionRequest.id, + 'type': 'data', + 'payload': {'data': mockSubscriptionData}, + }; + + mockWebSocketConnection.channel!.sink + .add(jsonEncode(mockSubscriptionEvent)); + + addTearDown(streamSub.cancel); + }); + + test('subscribe() should return a subscription stream', () async { + final establishedCompleter = Completer(); + final dataCompleter = Completer(); + const graphQLDocument = '''subscription MySubscription { + onCreateBlog { + id + name + createdAt + } + }'''; + final subscriptionRequest = + GraphQLRequest(document: graphQLDocument); + final subscription = Amplify.API.subscribe( + subscriptionRequest, + onEstablished: establishedCompleter.complete, + ); + + await assertWebSocketConnected( + mockWebSocketConnection, + subscriptionRequest.id, + ); + + final streamSub = subscription.listen( + (event) => dataCompleter.complete(event.data), + ); + await expectLater(establishedCompleter.future, completes); + + final mockSubscriptionEvent = { + 'id': subscriptionRequest.id, + 'type': 'data', + 'payload': {'data': mockSubscriptionData}, + }; + + mockWebSocketConnection.channel!.sink + .add(jsonEncode(mockSubscriptionEvent)); + + final subscriptionData = await dataCompleter.future; + expect(subscriptionData, json.encode(mockSubscriptionData)); + + addTearDown(streamSub.cancel); }); }); diff --git a/packages/api/amplify_api/test/util.dart b/packages/api/amplify_api/test/util.dart index 1a556c40a0..332846170a 100644 --- a/packages/api/amplify_api/test/util.dart +++ b/packages/api/amplify_api/test/util.dart @@ -28,9 +28,6 @@ import 'package:mocktail/mocktail.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:stream_channel/stream_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -import 'package:aws_signature_v4/aws_signature_v4.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter_test/flutter_test.dart'; const testAccessToken = 'test-access-token-123'; @@ -155,9 +152,38 @@ final failedHubEvent = isA().having( SubscriptionStatus.failed, ); +WebSocketChannel getMockWebSocketChannel(Uri uri) { + return MockWebSocketChannel(); +} + +WebSocketMessage startAck(String subscriptionID) => WebSocketMessage( + messageType: MessageType.startAck, + id: subscriptionID, + ); + +Future assertWebSocketConnected( + MockWebSocketConnection connection, + String subscriptionID, +) async { + await expectLater(connection.connectionPending, completes); + + connection.channel!.sink.add(jsonEncode(mockAckMessage)); + + await expectLater(connection.ready, completes); + + connection.channel!.sink.add(jsonEncode(startAck(subscriptionID))); +} + /// Extension of [WebSocketConnection] that stores messages internally instead /// of sending them. class MockWebSocketConnection extends WebSocketConnection { + MockWebSocketConnection( + super.config, + super.authProviderRepo, { + required super.logger, + super.subscriptionOptions, + }); + /// Instead of actually connecting, just set the URI here so it can be inspected /// for testing. Uri? connectedUri; @@ -166,16 +192,6 @@ class MockWebSocketConnection extends WebSocketConnection { /// inspected for testing. final List sentMessages = []; - MockWebSocketConnection( - super.config, - super.authProviderRepo, { - required super.logger, - super.clientOverride, - super.connectivityOverride, - super.subscriptionOptions, - super.webSocketFactoryOverride, - }); - WebSocketMessage? get lastSentMessage => sentMessages.lastOrNull; /// Pushes message in sentMessages and adds to stream (to support mocking result). @@ -188,26 +204,27 @@ class MockWebSocketConnection extends WebSocketConnection { // Mock WebSocket -class MockWebSocketSink extends DelegatingStreamSink implements WebSocketSink { +class MockWebSocketSink extends DelegatingStreamSink + implements WebSocketSink { MockWebSocketSink(super.sink); @override - Future close([int? closeCode, String? closeReason]) => super.close(); + Future close([int? closeCode, String? closeReason]) => super.close(); } class MockWebSocketChannel extends WebSocketChannel { + MockWebSocketChannel() : super(streamChannel) { + // controller.sink.add(mockAckMessage); + } + // ignore: close_sinks - final StreamController controller = StreamController.broadcast(); + final controller = StreamController.broadcast(); static StreamChannel> streamChannel = StreamChannel(const Stream.empty(), NullStreamSink()); - MockWebSocketChannel() : super(streamChannel) { - // controller.sink.add(mockAckMessage); - } - @override - Stream get stream => StreamView(controller.stream); + Stream get stream => StreamView(controller.stream); @override WebSocketSink get sink => MockWebSocketSink(controller.sink); 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 3064dacd2c..64a332b5e9 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,13 +15,12 @@ import 'dart:async'; import 'dart:convert'; -import 'package:amplify_api/src/graphql/ws/web_socket_types.dart'; +import 'package:amplify_api/src/graphql/ws/web_socket_connection.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:aws_common/testing.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus_platform_interface/connectivity_plus_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; import '../util.dart'; @@ -45,11 +44,6 @@ void main() { }'''; final subscriptionRequest = GraphQLRequest(document: graphQLDocument); - final startAck = WebSocketMessage( - messageType: MessageType.startAck, - id: subscriptionRequest.id, - ); - final mockSubscriptionData = { 'onCreateBlog': { 'id': '', @@ -66,58 +60,44 @@ void main() { }, }; - WebSocketChannel getWebSocketChannel(Uri uri) { - return MockWebSocketChannel(); - } + final mockClient = MockAWSHttpClient((request) async { + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode('healthy'), + ); + }); MockWebSocketConnection getWebSocketConnection() { - final mockClient = MockAWSHttpClient((request) async { - return AWSHttpResponse( - statusCode: 200, - body: utf8.encode('healthy'), - ); - }); - - fakePlatform = MockConnectivityPlatform(); - ConnectivityPlatform.instance = fakePlatform; - connectivity = Connectivity(); - const subscriptionOptions = GraphQLSubscriptionOptions(); - connection = MockWebSocketConnection( + return connection = MockWebSocketConnection( testApiKeyConfig, getTestAuthProviderRepo(), subscriptionOptions: subscriptionOptions, - connectivityOverride: connectivity, - clientOverride: mockClient, - webSocketFactoryOverride: getWebSocketChannel, logger: AmplifyLogger(), ); - - return connection; - } - - Future assertConnected() async { - await expectLater(connection.connectionPending, completes); - - connection.channel!.sink.add(jsonEncode(mockAckMessage)); - - await expectLater(connection.ready, completes); - - connection.channel!.sink.add(jsonEncode(startAck)); } group('WebSocketConnection', () { setUp(() { - // hubEventsController = StreamController(); - // hubEvents = hubEventsController.stream; - // Amplify.Hub.listen(HubChannel.Api, hubEventsController.add); + fakePlatform = MockConnectivityPlatform(); + ConnectivityPlatform.instance = fakePlatform; + connectivity = Connectivity(); + + WebSocketConnection.httpClientOverride = mockClient; + WebSocketConnection.connectivityOverride = connectivity; + WebSocketConnection.webSocketFactoryOverride = getMockWebSocketChannel; + + hubEventsController = StreamController.broadcast(); + hubEvents = hubEventsController.stream; + Amplify.Hub.listen(HubChannel.Api, hubEventsController.add); }); - tearDown(() { + tearDown(() async { connection.close(); - // Amplify.Hub.close(); - // hubEventsController.close(); + Amplify.Hub.close(); + await hubEventsController.close(); }); + tearDownAll(() {}); test('should init a connection & call onEstablishCallback', () async { getWebSocketConnection().subscribe( @@ -125,17 +105,17 @@ void main() { expectAsync0(() {}), ); - await assertConnected(); - - // expect( - // hubEvents, - // emitsInOrder( - // [ - // emitsThrough(connectingHubEvent), - // emitsThrough(connectedHubEvent), - // ], - // ), - // ); + expect( + hubEvents, + emitsInOrder( + [ + connectingHubEvent, + connectedHubEvent, + ], + ), + ); + + await assertWebSocketConnected(connection, subscriptionRequest.id); }); test('subscribe() should return a subscription stream', () async { @@ -144,7 +124,7 @@ void main() { () {}, ); - await assertConnected(); + await assertWebSocketConnected(connection, subscriptionRequest.id); final streamSub = subscription.listen( expectAsync1((event) { @@ -157,45 +137,46 @@ void main() { addTearDown(streamSub.cancel); }); - test('should reconnect when network turns on/off', () async { - Completer dataCompleter = Completer(); - final subscription = getWebSocketConnection().subscribe( - subscriptionRequest, - () {}, - ); + // todo(equartey): Implement reconnection logic tests + // test('should reconnect when network turns on/off', () async { + // final dataCompleter = Completer(); + // final subscription = getWebSocketConnection().subscribe( + // subscriptionRequest, + // () {}, + // ); - await assertConnected(); + // await assertWebSocketConnected(connection, subscriptionRequest.id); - final streamSub = subscription.listen( - expectAsync1( - (event) => dataCompleter.complete(event.data), - ), - ); + // final streamSub = subscription.listen( + // expectAsync1( + // (event) => dataCompleter.complete(event.data), + // ), + // ); - connection.channel!.sink.add(jsonEncode(mockSubscriptionEvent)); + // connection.channel!.sink.add(jsonEncode(mockSubscriptionEvent)); - fakePlatform.controller.sink.add(ConnectivityResult.none); + // fakePlatform.controller.sink.add(ConnectivityResult.none); - // expect( - // hubEvents, - // emitsInOrder( - // [ - // emitsThrough(connectingHubEvent), - // emitsThrough(connectedHubEvent), - // emitsThrough(connectingHubEvent), - // ], - // ), - // ); + // expect( + // hubEvents, + // emitsInOrder( + // [ + // connectingHubEvent, + // connectedHubEvent, + // connectingHubEvent, + // ], + // ), + // ); - addTearDown(streamSub.cancel); - }); + // addTearDown(streamSub.cancel); + // }); test('cancel() should send a stop message', () async { - Completer dataCompleter = Completer(); + final dataCompleter = Completer(); final subscription = getWebSocketConnection().subscribe(subscriptionRequest, null); - await assertConnected(); + await assertWebSocketConnected(connection, subscriptionRequest.id); final streamSub = subscription.listen( (event) => dataCompleter.complete(event.data), @@ -204,8 +185,12 @@ void main() { connection.channel!.sink.add(jsonEncode(mockSubscriptionEvent)); await dataCompleter.future; - streamSub.cancel(); - expect(connection.lastSentMessage!.messageType, MessageType.stop); + connection.channel!.stream.listen( + expectAsync1((event) { + expect(json.decode(event as String), containsPair('type', 'stop')); + }), + ); + await streamSub.cancel(); }); }); } From d3896e703fdf7a02de0863cd66977bfc4cce64bc Mon Sep 17 00:00:00 2001 From: equartey Date: Wed, 28 Sep 2022 15:30:15 -0500 Subject: [PATCH 44/47] Fixed pingClient init value --- .../amplify_api/lib/src/graphql/ws/web_socket_connection.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3e5e1a28ea..56e5e806bc 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 @@ -85,7 +85,7 @@ class WebSocketConnection implements Closeable { StreamController.broadcast(); RestartableTimer? _timeoutTimer; - late AWSHttpClient? _pingClient; + late AWSHttpClient? _pingClient = httpClientOverride ?? AWSHttpClient(); Timer? _pingTimer; late Uri _pingUri; bool _hasNetwork = false; From 6b5bf5013a11d3972d3e7522789c6ca5c0c186ec Mon Sep 17 00:00:00 2001 From: equartey Date: Thu, 29 Sep 2022 08:52:32 -0500 Subject: [PATCH 45/47] fix: some nits --- .../plugin/amplify_api_plugin_interface.dart | 1 - .../src/graphql/ws/web_socket_connection.dart | 4 +- packages/api/amplify_api/test/util.dart | 2 +- .../test/ws/web_socket_connection_test.dart | 69 ++++++++++--------- 4 files changed, 39 insertions(+), 37 deletions(-) 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 c80f7c0072..26f663131e 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 @@ -14,7 +14,6 @@ */ import 'package:amplify_core/amplify_core.dart'; -import 'package:async/async.dart'; import 'package:meta/meta.dart'; abstract class APIPluginInterface extends AmplifyPluginInterface { 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 56e5e806bc..bb595cca8d 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 @@ -85,7 +85,7 @@ class WebSocketConnection implements Closeable { StreamController.broadcast(); RestartableTimer? _timeoutTimer; - late AWSHttpClient? _pingClient = httpClientOverride ?? AWSHttpClient(); + AWSHttpClient? _pingClient = httpClientOverride ?? AWSHttpClient(); Timer? _pingTimer; late Uri _pingUri; bool _hasNetwork = false; @@ -475,7 +475,7 @@ class WebSocketConnection implements Closeable { _logger.info('Attempting to cancel Operation $subscriptionId'); send(WebSocketStopMessage(id: subscriptionId)); _subscriptionRequests.removeWhere((r) => r.id == subscriptionId); - // if (_subscriptionRequests.isEmpty) close(status.normalClosure); + if (_subscriptionRequests.isEmpty) close(status.normalClosure); } /// Serializes a message as JSON string and sends over WebSocket _channel. diff --git a/packages/api/amplify_api/test/util.dart b/packages/api/amplify_api/test/util.dart index 332846170a..c5a32a557b 100644 --- a/packages/api/amplify_api/test/util.dart +++ b/packages/api/amplify_api/test/util.dart @@ -137,7 +137,7 @@ final connectingHubEvent = isA().having( final disconnectedHubEvent = isA().having( (event) => event.status, 'status', - SubscriptionStatus.connecting, + SubscriptionStatus.disconnected, ); final pendingDisconnectedHubEvent = isA().having( 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 64a332b5e9..8a4df0cf37 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 @@ -137,39 +137,42 @@ void main() { addTearDown(streamSub.cancel); }); - // todo(equartey): Implement reconnection logic tests - // test('should reconnect when network turns on/off', () async { - // final dataCompleter = Completer(); - // final subscription = getWebSocketConnection().subscribe( - // subscriptionRequest, - // () {}, - // ); - - // await assertWebSocketConnected(connection, subscriptionRequest.id); - - // final streamSub = subscription.listen( - // expectAsync1( - // (event) => dataCompleter.complete(event.data), - // ), - // ); - - // connection.channel!.sink.add(jsonEncode(mockSubscriptionEvent)); - - // fakePlatform.controller.sink.add(ConnectivityResult.none); - - // expect( - // hubEvents, - // emitsInOrder( - // [ - // connectingHubEvent, - // connectedHubEvent, - // connectingHubEvent, - // ], - // ), - // ); - - // addTearDown(streamSub.cancel); - // }); + test( + 'should reconnect when network turns on/off', + () async { + final dataCompleter = Completer(); + final subscription = getWebSocketConnection().subscribe( + subscriptionRequest, + () {}, + ); + + await assertWebSocketConnected(connection, subscriptionRequest.id); + + final streamSub = subscription.listen( + expectAsync1( + (event) => dataCompleter.complete(event.data), + ), + ); + + connection.channel!.sink.add(jsonEncode(mockSubscriptionEvent)); + + fakePlatform.controller.sink.add(ConnectivityResult.none); + + expect( + hubEvents, + emitsInOrder( + [ + connectingHubEvent, + connectedHubEvent, + connectingHubEvent, + ], + ), + ); + + addTearDown(streamSub.cancel); + }, + skip: 'todo(equartey): Implement reconnection logic tests', + ); test('cancel() should send a stop message', () async { final dataCompleter = Completer(); From dc00c443a779a90b2e539e646ba6701886d3609b Mon Sep 17 00:00:00 2001 From: equartey Date: Fri, 30 Sep 2022 09:32:45 -0500 Subject: [PATCH 46/47] fix: some nits --- packages/api/amplify_api/example/lib/main.dart | 4 ++-- .../amplify_api/lib/src/graphql/ws/web_socket_connection.dart | 4 ++-- packages/api/amplify_api/test/util.dart | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/amplify_api/example/lib/main.dart b/packages/api/amplify_api/example/lib/main.dart index 016e5fe95e..5acce64558 100644 --- a/packages/api/amplify_api/example/lib/main.dart +++ b/packages/api/amplify_api/example/lib/main.dart @@ -62,11 +62,11 @@ class _MyAppState extends State { Amplify.Hub.listen( HubChannel.Api, - ((ApiHubEvent event) { + (ApiHubEvent event) { if (event is SubscriptionHubEvent) { safePrint(event); } - }), + }, ); } 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 bb595cca8d..2c4001461c 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 @@ -27,7 +27,7 @@ import 'package:web_socket_channel/status.dart' as status; import 'package:web_socket_channel/web_socket_channel.dart'; /// 1001, going away -const _defaultCloseStatus = status.goingAway; +const _defaultCloseStatus = status.normalClosure; const Duration _defaultPingInterval = Duration(seconds: 30); const Duration _defaultRetryTimeout = Duration(seconds: 5); @@ -475,7 +475,7 @@ class WebSocketConnection implements Closeable { _logger.info('Attempting to cancel Operation $subscriptionId'); send(WebSocketStopMessage(id: subscriptionId)); _subscriptionRequests.removeWhere((r) => r.id == subscriptionId); - if (_subscriptionRequests.isEmpty) close(status.normalClosure); + if (_subscriptionRequests.isEmpty) close(); } /// Serializes a message as JSON string and sends over WebSocket _channel. diff --git a/packages/api/amplify_api/test/util.dart b/packages/api/amplify_api/test/util.dart index c5a32a557b..acc5a6ba6a 100644 --- a/packages/api/amplify_api/test/util.dart +++ b/packages/api/amplify_api/test/util.dart @@ -224,7 +224,7 @@ class MockWebSocketChannel extends WebSocketChannel { StreamChannel(const Stream.empty(), NullStreamSink()); @override - Stream get stream => StreamView(controller.stream); + Stream get stream => controller.stream; @override WebSocketSink get sink => MockWebSocketSink(controller.sink); From b1f162b1ea9c1cd3e73d08682bdb83f0ddb01b31 Mon Sep 17 00:00:00 2001 From: equartey Date: Fri, 30 Sep 2022 11:50:48 -0500 Subject: [PATCH 47/47] fix: ios network none issue on second sub --- .../lib/src/graphql/ws/web_socket_connection.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 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 2c4001461c..42613c0a27 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 @@ -219,8 +219,15 @@ class WebSocketConnection implements Closeable { break; case ConnectivityResult.none: _hasNetwork = false; - _hasConnectionBroken = true; - _hubEventsController.add(SubscriptionHubEvent.connecting()); + // Only consider the connection broken if already established. + // Should be noted there is an issue with ios where ConnectivityResult + // is `none` despite a valid connection. Relevant issues are: + // https://github.com/fluttercommunity/plus_plugins/issues/852 + // https://github.com/fluttercommunity/plus_plugins/issues/858 + if (_connectionReady.isCompleted) { + _hasConnectionBroken = true; + _hubEventsController.add(SubscriptionHubEvent.connecting()); + } break; default: break;