From 5c080aa53b8641c4ca31091f5390913156727eb4 Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Mon, 2 Aug 2021 11:42:25 -0600 Subject: [PATCH] feat(amplify_api): GraphQL Response Decoding (#763) --- .../amplify_api/lib/method_channel_api.dart | 5 +- .../src/graphql/graphql_request_factory.dart | 52 +++++++---- .../src/graphql/graphql_response_decoder.dart | 55 +++++++++++ .../amplify_api_graphql_helpers_test.dart | 92 +++++++++++++++---- .../test/amplify_api_query_test.dart | 29 +++++- .../lib/src/GraphQL/GraphQLRequest.dart | 16 +++- 6 files changed, 206 insertions(+), 43 deletions(-) create mode 100644 packages/amplify_api/lib/src/graphql/graphql_response_decoder.dart diff --git a/packages/amplify_api/lib/method_channel_api.dart b/packages/amplify_api/lib/method_channel_api.dart index 8a19196378..0a59e36daf 100644 --- a/packages/amplify_api/lib/method_channel_api.dart +++ b/packages/amplify_api/lib/method_channel_api.dart @@ -16,6 +16,7 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:amplify_api/src/graphql/graphql_response_decoder.dart'; import 'package:amplify_core/types/index.dart'; import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart'; @@ -159,8 +160,8 @@ class AmplifyAPIMethodChannel extends AmplifyAPI { AmplifyExceptionMessages.nullReturnedFromMethodChannel); final errors = _deserializeGraphQLResponseErrors(result); - GraphQLResponse response = - GraphQLResponse(data: result['data'] ?? '', errors: errors); + GraphQLResponse response = GraphQLResponseDecoder.instance + .decode(request: request, data: result['data'], errors: errors); return response; } on PlatformException catch (e) { diff --git a/packages/amplify_api/lib/src/graphql/graphql_request_factory.dart b/packages/amplify_api/lib/src/graphql/graphql_request_factory.dart index 7b18ee3f24..8e362cbb67 100644 --- a/packages/amplify_api/lib/src/graphql/graphql_request_factory.dart +++ b/packages/amplify_api/lib/src/graphql/graphql_request_factory.dart @@ -32,6 +32,13 @@ class GraphQLRequestFactory { static GraphQLRequestFactory get instance => _instance; + String _getName(ModelSchema schema, GraphQLRequestOperation operation) { + // schema has been validated & schema.pluralName is non-nullable + return operation == GraphQLRequestOperation.list + ? schema.pluralName! + : schema.name; + } + String _getModelType(ModelFieldTypeEnum val) { switch (val) { case ModelFieldTypeEnum.string: @@ -45,13 +52,19 @@ class GraphQLRequestFactory { } } - String _getFieldsFromModelType(ModelSchema schema) { + String _getFieldsFromModelType( + ModelSchema schema, GraphQLRequestOperation operation) { // schema has been validated & schema.fields is non-nullable - Map fieldsMap = schema.fields!; - return fieldsMap.entries + String fields = schema.fields!.entries .map((entry) => entry.value.association == null ? entry.key : '') .toList() .join(' '); + + if (operation == GraphQLRequestOperation.list) { + fields = 'items { $fields } nextToken'; + } + + return fields; } ModelSchema _getAndValidateSchema( @@ -130,31 +143,34 @@ class GraphQLRequestFactory { String? id, required GraphQLRequestType requestType, required GraphQLRequestOperation requestOperation}) { + // retrieve schema from ModelType and validate required properties ModelSchema schema = _getAndValidateSchema(modelType, requestOperation); - // e.g. "Blog" - String name = schema.name; - // fields to retrieve, e.g. "id name createdAt" - String fields = _getFieldsFromModelType(schema); + // e.g. "Blog" or "Blogs" + String name = _getName(schema, requestOperation); // e.g. "query" String requestTypeVal = describeEnum(requestType); // e.g. "get" String requestOperationVal = describeEnum(requestOperation); - // {upper: "($id: ID!)", lower: "(id: $id)"} - DocumentInputs docInputs = _buildDocumentInputs(schema, requestOperation); - - if (requestOperation == GraphQLRequestOperation.list) { - name = schema.pluralName!; - fields = 'items { $fields } nextToken'; - } - - String doc = - '''$requestTypeVal $requestOperationVal$name${docInputs.upper} { $requestOperationVal$name${docInputs.lower} { $fields } }'''; + // e.g. {upper: "($id: ID!)", lower: "(id: $id)"} + DocumentInputs documentInputs = + _buildDocumentInputs(schema, requestOperation); + // e.g. "id name createdAt" - fields to retrieve + String fields = _getFieldsFromModelType(schema, requestOperation); + // e.g. "getBlog" + String requestName = "$requestOperationVal$name"; + // e.g. query getBlog($id: ID!, $content: String) { getBlog(id: $id, content: $content) { id name createdAt } } + String document = + '''$requestTypeVal $requestName${documentInputs.upper} { $requestName${documentInputs.lower} { $fields } }'''; // TODO: convert model to variable input for non-get operations Map variables = requestOperation == GraphQLRequestOperation.get ? {"id": id} : {}; - return GraphQLRequest(document: doc, variables: variables); + return GraphQLRequest( + document: document, + variables: variables, + modelType: modelType, + decodePath: requestName); } } diff --git a/packages/amplify_api/lib/src/graphql/graphql_response_decoder.dart b/packages/amplify_api/lib/src/graphql/graphql_response_decoder.dart new file mode 100644 index 0000000000..1e55e9af68 --- /dev/null +++ b/packages/amplify_api/lib/src/graphql/graphql_response_decoder.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +import 'package:amplify_api/amplify_api.dart'; + +class GraphQLResponseDecoder { + // Singleton methods/properties + // usage: GraphQLResponseDecoder.instance; + GraphQLResponseDecoder._(); + + static final GraphQLResponseDecoder _instance = GraphQLResponseDecoder._(); + + static GraphQLResponseDecoder get instance => _instance; + + GraphQLResponse decode( + {required GraphQLRequest request, + required String data, + required List errors}) { + // if no ModelType fallback to default + if (request.modelType == null) { + if (T == String) { + return GraphQLResponse( + data: data as T, errors: errors); // is implied + } else { + throw ApiException( + 'Decoding of the response type provided is currently unsupported', + recoverySuggestion: "Please provide a Model Type or type 'String'"); + } + } + + if (request.decodePath == null) { + throw ApiException('No decodePath found', + recoverySuggestion: 'Include decodePath when creating a request'); + } + + Map? dataJson = json.decode(data); + if (dataJson == null) { + throw ApiException( + 'Unable to decode json response, data received was null'); + } + + request.decodePath!.split(".").forEach((element) { + if (dataJson![element] == null) { + throw ApiException( + 'decodePath did not match the structure of the JSON response', + recoverySuggestion: + 'Include decodePath when creating a request that includes a modelType.'); + } + dataJson = dataJson![element]; + }); + + T decodedData = request.modelType!.fromJson(dataJson!) as T; + + return GraphQLResponse(data: decodedData, errors: errors); + } +} diff --git a/packages/amplify_api/test/amplify_api_graphql_helpers_test.dart b/packages/amplify_api/test/amplify_api_graphql_helpers_test.dart index 61b25889be..3c7467af56 100644 --- a/packages/amplify_api/test/amplify_api_graphql_helpers_test.dart +++ b/packages/amplify_api/test/amplify_api_graphql_helpers_test.dart @@ -15,35 +15,89 @@ import 'package:flutter/foundation.dart'; import 'package:amplify_api/amplify_api.dart'; +import 'package:amplify_api/src/graphql/graphql_response_decoder.dart'; import 'package:flutter_test/flutter_test.dart'; import 'resources/Blog.dart'; import 'resources/ModelProvider.dart'; void main() { - test("ModelQueries.get() should craft a valid request", () { - AmplifyAPI api = AmplifyAPI(modelProvider: ModelProvider.instance); + group('with ModelProvider', () { + final AmplifyAPI api = AmplifyAPI(modelProvider: ModelProvider.instance); - String id = UUID.getUUID(); - String expected = - r"query getBlog($id: ID!) { getBlog(id: $id) { id name createdAt } }"; + test("ModelQueries.get() should build a valid request", () { + String id = UUID.getUUID(); + String expected = + r"query getBlog($id: ID!) { getBlog(id: $id) { id name createdAt } }"; - GraphQLRequest req = ModelQueries.get(Blog.classType, id); + GraphQLRequest req = ModelQueries.get(Blog.classType, id); - expect(req.document, expected); - expect(mapEquals(req.variables, {'id': id}), isTrue); + expect(req.document, expected); + expect(mapEquals(req.variables, {'id': id}), isTrue); + expect(req.modelType, Blog.classType); + expect(req.decodePath, "getBlog"); + }); + + test('Query returns a GraphQLRequest when provided a modelType', + () async { + String id = UUID.getUUID(); + GraphQLRequest req = ModelQueries.get(Blog.classType, id); + List errors = []; + String data = '''{ + "getBlog": { + "createdAt": "2021-01-01T01:00:00.000000000Z", + "id": "$id", + "name": "TestAppBlog" + } + }'''; + + GraphQLResponse response = GraphQLResponseDecoder.instance + .decode(request: req, data: data, errors: errors); + + expect(response.data, isA()); + expect(response.data.id, id); + }); + + test('Query returns a GraphQLRequest when not provided a modelType', + () async { + String id = UUID.getUUID(); + String doc = '''query MyQuery { + getBlog { + id + name + createdAt + } + }'''; + GraphQLRequest req = + GraphQLRequest(document: doc, variables: {id: id}); + List errors = []; + String data = '''{ + "getBlog": { + "createdAt": "2021-01-01T01:00:00.000000000Z", + "id": "$id", + "name": "TestAppBlog" + } + }'''; + + GraphQLResponse response = GraphQLResponseDecoder.instance + .decode(request: req, data: data, errors: errors); + + expect(response.data, isA()); + }); }); - test("should handle no ModelProvider instance", () { - AmplifyAPI api = AmplifyAPI(); - try { - GraphQLRequest req = ModelQueries.get(Blog.classType, ""); - } on ApiException catch (e) { - expect(e.message, "No modelProvider found"); - expect(e.recoverySuggestion, - "Pass in a modelProvider instance while instantiating APIPlugin"); - return; - } - fail("Expected an ApiException"); + group('without ModelProvider', () { + test("should handle no ModelProvider instance", () { + AmplifyAPI api = AmplifyAPI(); + try { + GraphQLRequest req = ModelQueries.get(Blog.classType, ""); + } on ApiException catch (e) { + expect(e.message, "No modelProvider found"); + expect(e.recoverySuggestion, + "Pass in a modelProvider instance while instantiating APIPlugin"); + return; + } + fail("Expected an ApiException"); + }); }); } diff --git a/packages/amplify_api/test/amplify_api_query_test.dart b/packages/amplify_api/test/amplify_api_query_test.dart index 3b27be024d..005b4b2d4d 100644 --- a/packages/amplify_api/test/amplify_api_query_test.dart +++ b/packages/amplify_api/test/amplify_api_query_test.dart @@ -18,10 +18,13 @@ import 'package:amplify_api_plugin_interface/amplify_api_plugin_interface.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'resources/Blog.dart'; +import 'resources/ModelProvider.dart'; + void main() { const MethodChannel apiChannel = MethodChannel('com.amazonaws.amplify/api'); - AmplifyAPI api = AmplifyAPI(); + AmplifyAPI api = AmplifyAPI(modelProvider: ModelProvider.instance); TestWidgetsFlutterBinding.ensureInitialized(); @@ -78,6 +81,30 @@ void main() { expect(response.data, queryResult.toString()); }); + test('Query Model Helpers executes correctly in the happy case', () async { + final String id = UUID.getUUID(); + var queryResult = '''{ + "getBlog": { + "createdAt": "2021-07-21T22:23:33.707Z", + "id": "$id", + "name": "Test App Blog" + } + }'''; + + apiChannel.setMockMethodCallHandler((MethodCall methodCall) async { + return {'data': queryResult.toString(), 'errors': []}; + }); + + GraphQLRequest req = ModelQueries.get(Blog.classType, id); + + var operation = await api.query(request: req); + + var response = await operation.response; + + expect(response.data, isA()); + expect(response.data.id, id); + }); + test( 'A PlatformException for a failed API call results in the corresponding ApiException', () async { diff --git a/packages/amplify_api_plugin_interface/lib/src/GraphQL/GraphQLRequest.dart b/packages/amplify_api_plugin_interface/lib/src/GraphQL/GraphQLRequest.dart index 50a0e06975..f118ed2121 100644 --- a/packages/amplify_api_plugin_interface/lib/src/GraphQL/GraphQLRequest.dart +++ b/packages/amplify_api_plugin_interface/lib/src/GraphQL/GraphQLRequest.dart @@ -13,14 +13,24 @@ * permissions and limitations under the License. */ -import '../UUID.dart'; +// TODO: Datastore dependencies temporarily added in API. Eventually they should be moved to core or otherwise reconciled to avoid duplication. +import 'package:amplify_datastore_plugin_interface/amplify_datastore_plugin_interface.dart'; + +// TODO: Remove alias when Datastore dependency is removed +import '../UUID.dart' as API_UUID; class GraphQLRequest { String document; Map variables; - String cancelToken = UUID.getUUID(); + String cancelToken = API_UUID.UUID.getUUID(); + String? decodePath; + ModelType? modelType; - GraphQLRequest({required this.document, this.variables = const {}}); + GraphQLRequest( + {required this.document, + this.variables = const {}, + this.decodePath, + this.modelType}) {} Map serializeAsMap() { final Map result = {};