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 78c423b2ed..4a65b493bb 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 6bc92bf8cd..a0b6090c7a 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.8.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(() {