From 5e5a3878fbba2145ec9824f7648470e2f5a32305 Mon Sep 17 00:00:00 2001 From: davidroeca Date: Tue, 3 Jul 2018 18:17:57 -0400 Subject: [PATCH 1/9] Implement the multipart request spec --- flask_graphql/graphqlview.py | 13 +++++++++-- flask_graphql/utils.py | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 flask_graphql/utils.py diff --git a/flask_graphql/graphqlview.py b/flask_graphql/graphqlview.py index ff257b3..5b37d12 100644 --- a/flask_graphql/graphqlview.py +++ b/flask_graphql/graphqlview.py @@ -2,13 +2,13 @@ from flask import Response, request from flask.views import View - from graphql.type.schema import GraphQLSchema from graphql_server import (HttpQueryError, default_format_error, encode_execution_results, json_encode, load_json_body, run_http_query) from .render_graphiql import render_graphiql +from .utils import place_files_in_operations class GraphQLView(View): @@ -135,9 +135,18 @@ def parse_body(self): elif content_type == 'application/json': return load_json_body(request.data.decode('utf8')) - elif content_type in ('application/x-www-form-urlencoded', 'multipart/form-data'): + elif content_type in 'application/x-www-form-urlencoded': return request.form + elif content_type == 'multipart/form-data': + operations = load_json_body(request.form['operations']) + files_map = load_json_body(request.form['map']) + new_ops = place_files_in_operations( + operations, + files_map, + request.files + ) + return new_ops return {} def should_display_graphiql(self): diff --git a/flask_graphql/utils.py b/flask_graphql/utils.py new file mode 100644 index 0000000..455373c --- /dev/null +++ b/flask_graphql/utils.py @@ -0,0 +1,42 @@ +from copy import copy + + +def place_files_in_operations(operations, files_map, files): + paths_to_key = ( + (value.split('.'), key) + for key, values in files_map.items() + for value in values + ) + output = {} + output.update(operations) + for path, key in paths_to_key: + file_obj = files[key] + output = add_file_to_operations(output, file_obj, path) + return output + + +def add_file_to_operations(operations, file_obj, path): + if not path: + return file_obj + if isinstance(operations, dict): + key = path[0] + sub_dict = add_file_to_operations(operations[key], file_obj, path[1:]) + return merge_dicts( + operations, + {key: sub_dict}, + ) + if isinstance(operations, list): + index = int(path[0]) + sub_item = add_file_to_operations(operations[index], file_obj, path[1:]) + return operations[:index] + [sub_item] + operations[index+1:] + return TypeError('Operations must be a JSON data structure') + + +def merge_dicts(*dicts): + # Necessary for python2 support + if not dicts: + return {} + output = copy(dicts[0]) + for d in dicts[1:]: + output.update(d) + return output From 71d7749feab459c529e77dc775d93739e32e532b Mon Sep 17 00:00:00 2001 From: davidroeca Date: Tue, 3 Jul 2018 18:18:57 -0400 Subject: [PATCH 2/9] add tests --- tests/schema.py | 24 ++++++++++++++++++++++-- tests/test_graphqlview.py | 30 +++++++++++++++++------------- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/tests/schema.py b/tests/schema.py index f841672..807d561 100644 --- a/tests/schema.py +++ b/tests/schema.py @@ -1,12 +1,25 @@ from graphql.type.definition import GraphQLArgument, GraphQLField, GraphQLNonNull, GraphQLObjectType -from graphql.type.scalars import GraphQLString +from graphql.type.scalars import GraphQLString, GraphQLScalarType from graphql.type.schema import GraphQLSchema +def resolve_test_file(obj, info, what): + return what.readline().decode('utf-8') + + def resolve_raises(*_): raise Exception("Throws!") +# This scalar should be added to graphql-core at some point +GraphQLUpload = GraphQLScalarType( + name="Upload", + description="The `Upload` scalar type represents an uploaded file", + serialize=lambda x: None, + parse_value=lambda x: x, + parse_literal=lambda x: x, +) + QueryRootType = GraphQLObjectType( name='QueryRoot', fields={ @@ -21,7 +34,14 @@ def resolve_raises(*_): 'who': GraphQLArgument(GraphQLString) }, resolver=lambda obj, info, who='World': 'Hello %s' % who - ) + ), + 'testFile': GraphQLField( + type=GraphQLString, + args={ + 'what': GraphQLArgument(GraphQLNonNull(GraphQLUpload)), + }, + resolver=resolve_test_file, + ), } ) diff --git a/tests/test_graphqlview.py b/tests/test_graphqlview.py index 77626d4..65e88ac 100644 --- a/tests/test_graphqlview.py +++ b/tests/test_graphqlview.py @@ -1,5 +1,6 @@ import pytest import json +import tempfile try: from StringIO import StringIO @@ -465,18 +466,21 @@ def test_supports_pretty_printing(client): def test_post_multipart_data(client): - query = 'mutation TestMutation { writeTest { test } }' - response = client.post( - url_string(), - data= { - 'query': query, - 'file': (StringIO(), 'text1.txt'), - }, - content_type='multipart/form-data' - ) - + query = 'mutation TestMutation($file: Upload!) { writeTest { testFile( what: $file ) } }' + with tempfile.NamedTemporaryFile() as t_file: + t_file.write(b'Fake Data\nLine2\n') + t_file.seek(0) + response = client.post( + url_string(), + data={ + 'operations': j(query=query, variables={'file': None}), + 'file': t_file, + 'map': j(file=["variables.file"]), + }, + content_type='multipart/form-data' + ) assert response.status_code == 200 - assert response_json(response) == {'data': {u'writeTest': {u'test': u'Hello World'}}} + assert response_json(response) == {'data': {u'writeTest': {u'testFile': u'Fake Data\n'}}} @pytest.mark.parametrize('app', [create_app(batch=True)]) @@ -514,8 +518,8 @@ def test_batch_supports_post_json_query_with_json_variables(client): # 'id': 1, 'data': {'test': "Hello Dolly"} }] - - + + @pytest.mark.parametrize('app', [create_app(batch=True)]) def test_batch_allows_post_with_operation_name(client): response = client.post( From 981444673b62bc7d34122d023c7ad5a6bf60655e Mon Sep 17 00:00:00 2001 From: davidroeca Date: Thu, 5 Jul 2018 17:33:08 -0400 Subject: [PATCH 3/9] mild refactoring --- flask_graphql/utils.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/flask_graphql/utils.py b/flask_graphql/utils.py index 455373c..83df710 100644 --- a/flask_graphql/utils.py +++ b/flask_graphql/utils.py @@ -1,13 +1,10 @@ -from copy import copy - - def place_files_in_operations(operations, files_map, files): paths_to_key = ( (value.split('.'), key) for key, values in files_map.items() for value in values ) - output = {} + output = new_merged_dict(operations) output.update(operations) for path, key in paths_to_key: file_obj = files[key] @@ -21,22 +18,24 @@ def add_file_to_operations(operations, file_obj, path): if isinstance(operations, dict): key = path[0] sub_dict = add_file_to_operations(operations[key], file_obj, path[1:]) - return merge_dicts( - operations, - {key: sub_dict}, - ) + return new_merged_dict(operations, {key: sub_dict}) if isinstance(operations, list): index = int(path[0]) sub_item = add_file_to_operations(operations[index], file_obj, path[1:]) - return operations[:index] + [sub_item] + operations[index+1:] + return new_list_with_replaced_item(operations, index, sub_item) return TypeError('Operations must be a JSON data structure') -def merge_dicts(*dicts): +def new_merged_dict(*dicts): # Necessary for python2 support - if not dicts: - return {} - output = copy(dicts[0]) - for d in dicts[1:]: + output = {} + for d in dicts: output.update(d) return output + + +def new_list_with_replaced_item(input_list, index, new_value): + # Necessary for python2 support + output = [i for i in input_list] + output[index] = new_value + return output From bc845e82b1e4aaf2f28141a445e94485da9c62d3 Mon Sep 17 00:00:00 2001 From: davidroeca Date: Thu, 5 Jul 2018 17:38:05 -0400 Subject: [PATCH 4/9] modify variable names/comments --- flask_graphql/utils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/flask_graphql/utils.py b/flask_graphql/utils.py index 83df710..f26ec02 100644 --- a/flask_graphql/utils.py +++ b/flask_graphql/utils.py @@ -1,12 +1,13 @@ def place_files_in_operations(operations, files_map, files): - paths_to_key = ( + path_to_key_iter = ( (value.split('.'), key) for key, values in files_map.items() for value in values ) - output = new_merged_dict(operations) - output.update(operations) - for path, key in paths_to_key: + # Since add_files_to_operations returns a new dict/list, first define + # output to be operations itself + output = operations + for path, key in path_to_key_iter: file_obj = files[key] output = add_file_to_operations(output, file_obj, path) return output From fde7a404559265f7b834f828248dfd03ed760e31 Mon Sep 17 00:00:00 2001 From: davidroeca Date: Thu, 5 Jul 2018 17:41:13 -0400 Subject: [PATCH 5/9] fix isort --- flask_graphql/graphqlview.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flask_graphql/graphqlview.py b/flask_graphql/graphqlview.py index 5b37d12..15477f2 100644 --- a/flask_graphql/graphqlview.py +++ b/flask_graphql/graphqlview.py @@ -2,6 +2,7 @@ from flask import Response, request from flask.views import View + from graphql.type.schema import GraphQLSchema from graphql_server import (HttpQueryError, default_format_error, encode_execution_results, json_encode, From 92b83ac0f20dffe419ecc546f3c217b921faf6dd Mon Sep 17 00:00:00 2001 From: davidroeca Date: Thu, 5 Jul 2018 18:36:52 -0400 Subject: [PATCH 6/9] test more of the spec --- tests/schema.py | 20 ++++++++++++++-- tests/test_graphqlview.py | 49 +++++++++++++++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/tests/schema.py b/tests/schema.py index 807d561..7abde18 100644 --- a/tests/schema.py +++ b/tests/schema.py @@ -1,10 +1,19 @@ -from graphql.type.definition import GraphQLArgument, GraphQLField, GraphQLNonNull, GraphQLObjectType +from graphql.type.definition import GraphQLArgument, GraphQLField, GraphQLNonNull, GraphQLObjectType, GraphQLList from graphql.type.scalars import GraphQLString, GraphQLScalarType from graphql.type.schema import GraphQLSchema def resolve_test_file(obj, info, what): - return what.readline().decode('utf-8') + output = what.readline().decode('utf-8') + what.seek(0) + return output + + +def resolve_test_files(obj, info, whats): + output = ''.join(what.readline().decode('utf-8') for what in whats) + for what in whats: + what.seek(0) + return output def resolve_raises(*_): @@ -42,6 +51,13 @@ def resolve_raises(*_): }, resolver=resolve_test_file, ), + 'testMultiFile': GraphQLField( + type=GraphQLString, + args={ + 'whats': GraphQLArgument(GraphQLNonNull(GraphQLList(GraphQLUpload))), + }, + resolver=resolve_test_files, + ) } ) diff --git a/tests/test_graphqlview.py b/tests/test_graphqlview.py index 65e88ac..ad3a692 100644 --- a/tests/test_graphqlview.py +++ b/tests/test_graphqlview.py @@ -1,6 +1,6 @@ import pytest import json -import tempfile +from tempfile import NamedTemporaryFile try: from StringIO import StringIO @@ -467,15 +467,15 @@ def test_supports_pretty_printing(client): def test_post_multipart_data(client): query = 'mutation TestMutation($file: Upload!) { writeTest { testFile( what: $file ) } }' - with tempfile.NamedTemporaryFile() as t_file: + with NamedTemporaryFile() as t_file: t_file.write(b'Fake Data\nLine2\n') t_file.seek(0) response = client.post( url_string(), data={ 'operations': j(query=query, variables={'file': None}), - 'file': t_file, - 'map': j(file=["variables.file"]), + 't_file': t_file, + 'map': j(t_file=["variables.file"]), }, content_type='multipart/form-data' ) @@ -483,6 +483,47 @@ def test_post_multipart_data(client): assert response_json(response) == {'data': {u'writeTest': {u'testFile': u'Fake Data\n'}}} +@pytest.mark.parametrize('app', [create_app(batch=True)]) +def test_post_multipart_data_multi(client): + query1 = ''' + mutation TestMutation($file: Upload!) { + writeTest { testFile( what: $file ) } + }''' + query2 = ''' + mutation TestMutation($files: [Upload]!) { + writeTest { testMultiFile( whats: $files ) } + }''' + with NamedTemporaryFile() as tf1, NamedTemporaryFile() as tf2: + tf1.write(b'tf1\nNot This line!!\n') + tf1.seek(0) + tf2.write(b'tf2\nNot This line!!\n') + tf2.seek(0) + response = client.post( + url_string(), + data={ + 'operations': json.dumps([ + {'query': query1, 'variables': {'file': None}}, + {'query': query2, 'variables': {'files': [None, None]}}, + ]), + 'tf1': tf1, + 'tf2': tf2, + 'map': j( + tf1=['0.variables.file', '1.variables.files.0'], + tf2=['1.variables.files.1'], + ), + }, + content_type='multipart/form-data' + ) + assert response.status_code == 200 + assert response_json(response) == [ + {'data': { + u'writeTest': {u'testFile': u'tf1\n'} + }}, + {'data': { + u'writeTest': {u'testMultiFile': u'tf1\ntf2\n'} + }}, + ] + @pytest.mark.parametrize('app', [create_app(batch=True)]) def test_batch_allows_post_with_json_encoding(client): response = client.post( From 13c6cef7f00798c15d9695c20a1eb52151591f29 Mon Sep 17 00:00:00 2001 From: davidroeca Date: Thu, 5 Jul 2018 18:38:42 -0400 Subject: [PATCH 7/9] consistent styles --- tests/test_graphqlview.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_graphqlview.py b/tests/test_graphqlview.py index ad3a692..9277898 100644 --- a/tests/test_graphqlview.py +++ b/tests/test_graphqlview.py @@ -524,6 +524,7 @@ def test_post_multipart_data_multi(client): }}, ] + @pytest.mark.parametrize('app', [create_app(batch=True)]) def test_batch_allows_post_with_json_encoding(client): response = client.post( From 466a26e836148ad18fc0ab44192a503ce4d51f1d Mon Sep 17 00:00:00 2001 From: davidroeca Date: Thu, 5 Jul 2018 18:42:30 -0400 Subject: [PATCH 8/9] remove unnecessary variable --- flask_graphql/graphqlview.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flask_graphql/graphqlview.py b/flask_graphql/graphqlview.py index 15477f2..6a01451 100644 --- a/flask_graphql/graphqlview.py +++ b/flask_graphql/graphqlview.py @@ -142,12 +142,11 @@ def parse_body(self): elif content_type == 'multipart/form-data': operations = load_json_body(request.form['operations']) files_map = load_json_body(request.form['map']) - new_ops = place_files_in_operations( + return place_files_in_operations( operations, files_map, request.files ) - return new_ops return {} def should_display_graphiql(self): From b503828bdc36f6ec87b36873289ec94ac2cffed7 Mon Sep 17 00:00:00 2001 From: davidroeca Date: Thu, 5 Jul 2018 22:24:49 -0400 Subject: [PATCH 9/9] fix x-www-form-urlencode case --- flask_graphql/graphqlview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_graphql/graphqlview.py b/flask_graphql/graphqlview.py index 6a01451..f846b8e 100644 --- a/flask_graphql/graphqlview.py +++ b/flask_graphql/graphqlview.py @@ -136,7 +136,7 @@ def parse_body(self): elif content_type == 'application/json': return load_json_body(request.data.decode('utf8')) - elif content_type in 'application/x-www-form-urlencoded': + elif content_type == 'application/x-www-form-urlencoded': return request.form elif content_type == 'multipart/form-data':