From fa0e957788b89e1e336da3cc3b9213b44e8bce87 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Tue, 13 Aug 2019 22:44:04 -0400 Subject: [PATCH 1/4] Experimental implement of file upload spec --- angel_graphql/lib/src/graphql_http.dart | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/angel_graphql/lib/src/graphql_http.dart b/angel_graphql/lib/src/graphql_http.dart index 61e11305..b2c20bd0 100644 --- a/angel_graphql/lib/src/graphql_http.dart +++ b/angel_graphql/lib/src/graphql_http.dart @@ -16,6 +16,8 @@ final Validator graphQlPostBody = new Validator({ 'variables': predicate((v) => v == null || v is String || v is Map), }); +final RegExp _num = RegExp(r'^[0-9]+$'); + /// A [RequestHandler] that serves a spec-compliant GraphQL backend. /// /// Follows the guidelines listed here: @@ -80,6 +82,35 @@ RequestHandler graphQLHttp(GraphQL graphQL, if (await validate(graphQlPostBody)(req, res) as bool) { return await executeMap(req.bodyAsMap); } + } else if (req.headers.contentType?.mimeType == 'multipart/form-data') { + // TODO: Support file uploads in batch requests. + var fields = await req.parseBody().then((_) => req.bodyAsMap); + var operations = fields['operations'] as String; + var map = fields.containsKey('map') + ? json.decode(fields['map'] as String) + : null; + var variables = Map.from(globalVariables); + for (var entry in (map as Map).entries) { + var key = int.parse(entry.key as String); + var objectPaths = entry.value as List; + for (var objectPath in objectPaths) { + var subPaths = (objectPath as String).split('.'); + if (subPaths[0] == 'variables') { + var current = variables; + for (int i = 0; i < subPaths.length; i++) { + var name = subPaths[0]; + if (_num.hasMatch(name)) { + (current as List)[int.parse(name)] = req.uploadedFiles[key]; + } + } + } + } + } + return await sendGraphQLResponse(await graphQL.parseAndExecute( + operations, + sourceUrl: 'input', + globalVariables: variables, + )); } else { throw new AngelHttpException.badRequest(); } From 36ab0cdb2a566c921a81c9e854f79ba682497139 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Tue, 13 Aug 2019 22:50:04 -0400 Subject: [PATCH 2/4] Create graphQLUpload schema type --- angel_graphql/lib/angel_graphql.dart | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/angel_graphql/lib/angel_graphql.dart b/angel_graphql/lib/angel_graphql.dart index 7673a86e..780cc100 100644 --- a/angel_graphql/lib/angel_graphql.dart +++ b/angel_graphql/lib/angel_graphql.dart @@ -1,4 +1,46 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:graphql_schema/graphql_schema.dart'; export 'src/graphiql.dart'; export 'src/graphql_http.dart'; export 'src/graphql_ws.dart'; export 'src/resolvers.dart'; + +/// The canonical [GraphQLUploadType] instance. +final GraphQLUploadType graphQLUpload = GraphQLUploadType(); + +/// A [GraphQLScalarType] that is used to read uploaded files from +/// `multipart/form-data` requests. +class GraphQLUploadType extends GraphQLScalarType { + @override + String get name => 'Upload'; + + @override + String get description => + 'Represents a file that has been uploaded to the server.'; + + @override + GraphQLType coerceToInputObject() => this; + + @override + UploadedFile deserialize(UploadedFile serialized) => serialized; + + @override + UploadedFile serialize(UploadedFile value) => value; + + @override + ValidationResult validate(String key, UploadedFile input) { + if (input != null && input is! UploadedFile) { + return _Vr(false, errors: ['Expected "$key" to be a boolean.']); + } + return _Vr(true, value: input); + } +} + +// TODO: Really need to make the validation result constructors *public* +class _Vr implements ValidationResult { + final bool successful; + final List errors; + final T value; + + _Vr(this.successful, {this.errors, this.value}); +} From b2fe33f0adb9cc99314f2dbafc6403716424b50e Mon Sep 17 00:00:00 2001 From: Tobe O Date: Tue, 13 Aug 2019 22:55:46 -0400 Subject: [PATCH 3/4] Add validation --- angel_graphql/lib/src/graphql_http.dart | 39 ++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/angel_graphql/lib/src/graphql_http.dart b/angel_graphql/lib/src/graphql_http.dart index b2c20bd0..6ee80a83 100644 --- a/angel_graphql/lib/src/graphql_http.dart +++ b/angel_graphql/lib/src/graphql_http.dart @@ -86,23 +86,60 @@ RequestHandler graphQLHttp(GraphQL graphQL, // TODO: Support file uploads in batch requests. var fields = await req.parseBody().then((_) => req.bodyAsMap); var operations = fields['operations'] as String; + if (operations == null) { + throw AngelHttpException.badRequest( + message: 'Missing "operations" field.'); + } var map = fields.containsKey('map') ? json.decode(fields['map'] as String) : null; + if (map is! Map) { + throw AngelHttpException.badRequest( + message: '"map" field must decode to a JSON object.'); + } var variables = Map.from(globalVariables); for (var entry in (map as Map).entries) { var key = int.parse(entry.key as String); + if (req.uploadedFiles.length < key) { + throw AngelHttpException.badRequest( + message: + '"map" contained key "$key", but the number of uploaded files was ${req.uploadedFiles.length}.'); + } + if (entry.value is! List) { + throw AngelHttpException.badRequest( + message: + 'The value for "$key" in the "map" field was not a JSON array.'); + } var objectPaths = entry.value as List; for (var objectPath in objectPaths) { var subPaths = (objectPath as String).split('.'); if (subPaths[0] == 'variables') { - var current = variables; + Object current = variables; for (int i = 0; i < subPaths.length; i++) { var name = subPaths[0]; + var parent = subPaths.take(i).join('.'); if (_num.hasMatch(name)) { + if (current is! List) { + throw AngelHttpException.badRequest( + message: + 'Object "$parent" is not a JSON array, but the ' + '"map" field contained a mapping to $parent.$name.'); + } (current as List)[int.parse(name)] = req.uploadedFiles[key]; + } else { + if (current is! Map) { + throw AngelHttpException.badRequest( + message: + 'Object "$parent" is not a JSON object, but the ' + '"map" field contained a mapping to $parent.$name.'); + } + (current as Map)[name] = req.uploadedFiles[key]; } } + } else { + throw AngelHttpException.badRequest( + message: + 'All array values in the "map" field must begin with "variables.".'); } } } From 9fd290ca683cb7a2f9289d87d0f0f65e3f3e13b2 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Tue, 13 Aug 2019 22:58:15 -0400 Subject: [PATCH 4/4] Use name instead of parsed int index --- angel_graphql/lib/src/graphql_http.dart | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/angel_graphql/lib/src/graphql_http.dart b/angel_graphql/lib/src/graphql_http.dart index 6ee80a83..5f71a031 100644 --- a/angel_graphql/lib/src/graphql_http.dart +++ b/angel_graphql/lib/src/graphql_http.dart @@ -99,16 +99,18 @@ RequestHandler graphQLHttp(GraphQL graphQL, } var variables = Map.from(globalVariables); for (var entry in (map as Map).entries) { - var key = int.parse(entry.key as String); - if (req.uploadedFiles.length < key) { + var file = req.uploadedFiles + .firstWhere((f) => f.name == entry.key, orElse: () => null); + if (file == null) { throw AngelHttpException.badRequest( message: - '"map" contained key "$key", but the number of uploaded files was ${req.uploadedFiles.length}.'); + '"map" contained key "${entry.key}", but no uploaded file ' + 'has that name.'); } if (entry.value is! List) { throw AngelHttpException.badRequest( message: - 'The value for "$key" in the "map" field was not a JSON array.'); + 'The value for "${entry.key}" in the "map" field was not a JSON array.'); } var objectPaths = entry.value as List; for (var objectPath in objectPaths) { @@ -125,7 +127,7 @@ RequestHandler graphQLHttp(GraphQL graphQL, 'Object "$parent" is not a JSON array, but the ' '"map" field contained a mapping to $parent.$name.'); } - (current as List)[int.parse(name)] = req.uploadedFiles[key]; + (current as List)[int.parse(name)] = file; } else { if (current is! Map) { throw AngelHttpException.badRequest( @@ -133,7 +135,7 @@ RequestHandler graphQLHttp(GraphQL graphQL, 'Object "$parent" is not a JSON object, but the ' '"map" field contained a mapping to $parent.$name.'); } - (current as Map)[name] = req.uploadedFiles[key]; + (current as Map)[name] = file; } } } else {