From 4895df1f14ecb5b33b9434ff673a91d421f2336e Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Mon, 27 Jun 2022 14:28:39 -0800 Subject: [PATCH] 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 1e979849ea..b25c52ebb1 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); + }); + }); +}