Skip to content

Commit

Permalink
feat(amplify_api): GraphQL Response Decoding (#763)
Browse files Browse the repository at this point in the history
  • Loading branch information
Equartey authored Aug 2, 2021
1 parent 858ac2f commit 5c080aa
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 43 deletions.
5 changes: 3 additions & 2 deletions packages/amplify_api/lib/method_channel_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -159,8 +160,8 @@ class AmplifyAPIMethodChannel extends AmplifyAPI {
AmplifyExceptionMessages.nullReturnedFromMethodChannel);
final errors = _deserializeGraphQLResponseErrors(result);

GraphQLResponse<T> response =
GraphQLResponse<T>(data: result['data'] ?? '', errors: errors);
GraphQLResponse<T> response = GraphQLResponseDecoder.instance
.decode<T>(request: request, data: result['data'], errors: errors);

return response;
} on PlatformException catch (e) {
Expand Down
52 changes: 34 additions & 18 deletions packages/amplify_api/lib/src/graphql/graphql_request_factory.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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<String, ModelField> 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(
Expand Down Expand Up @@ -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<String, dynamic> variables =
requestOperation == GraphQLRequestOperation.get ? {"id": id} : {};

return GraphQLRequest<T>(document: doc, variables: variables);
return GraphQLRequest<T>(
document: document,
variables: variables,
modelType: modelType,
decodePath: requestName);
}
}
55 changes: 55 additions & 0 deletions packages/amplify_api/lib/src/graphql/graphql_response_decoder.dart
Original file line number Diff line number Diff line change
@@ -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<T> decode<T>(
{required GraphQLRequest request,
required String data,
required List<GraphQLResponseError> errors}) {
// if no ModelType fallback to default
if (request.modelType == null) {
if (T == String) {
return GraphQLResponse(
data: data as T, errors: errors); // <T> 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<String, dynamic>? 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<T>(data: decodedData, errors: errors);
}
}
92 changes: 73 additions & 19 deletions packages/amplify_api/test/amplify_api_graphql_helpers_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Blog> req = ModelQueries.get<Blog>(Blog.classType, id);
GraphQLRequest<Blog> req = ModelQueries.get<Blog>(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<Blog> when provided a modelType',
() async {
String id = UUID.getUUID();
GraphQLRequest<Blog> req = ModelQueries.get<Blog>(Blog.classType, id);
List<GraphQLResponseError> errors = [];
String data = '''{
"getBlog": {
"createdAt": "2021-01-01T01:00:00.000000000Z",
"id": "$id",
"name": "TestAppBlog"
}
}''';

GraphQLResponse<Blog> response = GraphQLResponseDecoder.instance
.decode<Blog>(request: req, data: data, errors: errors);

expect(response.data, isA<Blog>());
expect(response.data.id, id);
});

test('Query returns a GraphQLRequest<String> when not provided a modelType',
() async {
String id = UUID.getUUID();
String doc = '''query MyQuery {
getBlog {
id
name
createdAt
}
}''';
GraphQLRequest<String> req =
GraphQLRequest(document: doc, variables: {id: id});
List<GraphQLResponseError> errors = [];
String data = '''{
"getBlog": {
"createdAt": "2021-01-01T01:00:00.000000000Z",
"id": "$id",
"name": "TestAppBlog"
}
}''';

GraphQLResponse<String> response = GraphQLResponseDecoder.instance
.decode<String>(request: req, data: data, errors: errors);

expect(response.data, isA<String>());
});
});

test("should handle no ModelProvider instance", () {
AmplifyAPI api = AmplifyAPI();
try {
GraphQLRequest<Blog> req = ModelQueries.get<Blog>(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<Blog> req = ModelQueries.get<Blog>(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");
});
});
}
29 changes: 28 additions & 1 deletion packages/amplify_api/test/amplify_api_query_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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<Blog> req = ModelQueries.get<Blog>(Blog.classType, id);

var operation = await api.query<Blog>(request: req);

var response = await operation.response;

expect(response.data, isA<Blog>());
expect(response.data.id, id);
});

test(
'A PlatformException for a failed API call results in the corresponding ApiException',
() async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
String document;
Map<String, dynamic> 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<String, dynamic> serializeAsMap() {
final Map<String, dynamic> result = <String, dynamic>{};
Expand Down

0 comments on commit 5c080aa

Please sign in to comment.