Repository: strawberry-graphql/strawberry

Issue:
Add support for uploading files
I think we’d benefit from having built-in support for upload files, similar to what Apollo server does: https://www.apollographql.com/blog/file-uploads-with-apollo-server-2-0-5db2f3f60675

I did implement something like this in a side project, so shouldn’t be too hard to do. 

Support should be available for Flask, Django and ASGI :)

- [x] Django
- [x] Flask
- [x] ASGI
- [ ] Sanic 


Discussion:
This is how one graphene implementation that I use in production does it. I use apollo client on the front end and it works well.

Pretty simple
https://github.com/lmcgartland/graphene-file-upload/blob/master/graphene_file_upload/django/__init__.py

there is also a flask version of this file in the repo
First version for django is here, we'll test this in our repo and then add support for ASGI and Flask :) 

https://github.com/strawberry-graphql/strawberry/pull/407
@patrick91 I think we can close this now. 
I think sanic is missing upload support :)
Ah ok. I've added it as a todo list to the description.

Resolving patch:
diff --git a/strawberry/sanic/utils.py b/strawberry/sanic/utils.py
new file mode 100644
--- /dev/null
+++ b/strawberry/sanic/utils.py
@@ -0,0 +1,38 @@
+from io import BytesIO
+from typing import Dict, List, Union
+
+from sanic.request import Request
+
+
+def convert_request_to_files_dict(request: Request) -> dict:
+    """
+    request.files has the following format, even if only a single file is uploaded:
+
+    {
+        'textFile': [
+            sanic.request.File(
+                type='text/plain',
+                body=b'strawberry',
+                name='textFile.txt'
+            )
+        ]
+    }
+
+    Note that the dictionary entries are lists.
+    """
+    request_files: dict = request.files
+    files_dict: Dict[str, Union[BytesIO, List[BytesIO]]] = {}
+
+    for field_name, file_list in request_files.items():
+        if len(file_list) == 1:
+            # Turn single item file lists into a single file object
+            files_dict[field_name] = BytesIO(file_list[0].body)
+        else:  # pragma: no cover
+            # TODO: Test this branch once file list uploads are supported (see #766).
+            # https://github.com/strawberry-graphql/strawberry/issues/766
+
+            # Preserve actual file lists for file list upload support
+            file_object_list = [BytesIO(file_item.body) for file_item in file_list]
+            files_dict[field_name] = file_object_list
+
+    return files_dict
diff --git a/strawberry/sanic/views.py b/strawberry/sanic/views.py
--- a/strawberry/sanic/views.py
+++ b/strawberry/sanic/views.py
@@ -1,16 +1,18 @@
 import json
 from typing import Any
 
-from sanic.exceptions import abort
+from sanic.exceptions import ServerError, abort
 from sanic.request import Request
 from sanic.response import HTTPResponse, html
 from sanic.views import HTTPMethodView
+from strawberry.file_uploads.data import replace_placeholders_with_files
 from strawberry.http import GraphQLHTTPResponse, process_result
-from strawberry.types import ExecutionResult
+from strawberry.types import ExecutionContext, ExecutionResult
 
 from ..schema import BaseSchema
 from .context import StrawberrySanicContext
 from .graphiql import render_graphiql_page
+from .utils import convert_request_to_files_dict
 
 
 class GraphQLView(HTTPMethodView):
@@ -62,22 +64,16 @@ async def dispatch_request(self, request: Request):  # type: ignore
             template = render_graphiql_page()
             return self.render_template(template=template)
 
-        data = request.json
-        try:
-            query = data["query"]
-        except KeyError:
-            return HTTPResponse("No valid query was provided for the request", 400)
-
-        variables = data.get("variables")
-        operation_name = data.get("operationName")
+        operation_context = self.get_execution_context(request)
         context = await self.get_context(request)
+        root_value = self.get_root_value()
 
         result = await self.schema.execute(
-            query,
-            variable_values=variables,
+            query=operation_context.query,
+            variable_values=operation_context.variables,
             context_value=context,
-            operation_name=operation_name,
-            root_value=self.get_root_value(),
+            root_value=root_value,
+            operation_name=operation_context.operation_name,
         )
         response_data = self.process_result(result)
 
@@ -85,6 +81,35 @@ async def dispatch_request(self, request: Request):  # type: ignore
             json.dumps(response_data), status=200, content_type="application/json"
         )
 
+    def get_execution_context(self, request: Request) -> ExecutionContext:
+        try:
+            data = self.parse_body(request)
+        except json.JSONDecodeError:
+            raise ServerError("Unable to parse request body as JSON", status_code=400)
+
+        try:
+            query = data["query"]
+        except KeyError:
+            raise ServerError("No GraphQL query found in the request", status_code=400)
+
+        variables = data.get("variables")
+        operation_name = data.get("operationName")
+
+        return ExecutionContext(
+            query=query, variables=variables, operation_name=operation_name
+        )
+
+    def parse_body(self, request: Request) -> dict:
+        if request.content_type.startswith("multipart/form-data"):
+            files = convert_request_to_files_dict(request)
+            operations = json.loads(request.form.get("operations", "{}"))
+            files_map = json.loads(request.form.get("map", "{}"))
+            try:
+                return replace_placeholders_with_files(operations, files_map, files)
+            except KeyError:
+                abort(400, "File(s) missing in form data")
+        return request.json
+
     def should_display_graphiql(self, request):
         if not self.graphiql:
             return False


Test patch:
diff --git a/tests/sanic/app.py b/tests/sanic/app.py
new file mode 100644
--- /dev/null
+++ b/tests/sanic/app.py
@@ -0,0 +1,32 @@
+from random import random
+
+import strawberry
+from sanic import Sanic
+from strawberry.file_uploads import Upload
+from strawberry.sanic.views import GraphQLView as BaseGraphQLView
+
+
+def create_app(**kwargs):
+    @strawberry.type
+    class Query:
+        hello: str = "strawberry"
+
+    @strawberry.type
+    class Mutation:
+        @strawberry.mutation
+        def read_text(self, text_file: Upload) -> str:
+            return text_file.read().decode()
+
+    schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+    class GraphQLView(BaseGraphQLView):
+        def get_root_value(self):
+            return Query()
+
+    app = Sanic(f"test-app-{random()}")
+
+    app.add_route(
+        GraphQLView.as_view(schema=schema, graphiql=kwargs.get("graphiql", True)),
+        "/graphql",
+    )
+    return app
diff --git a/tests/sanic/conftest.py b/tests/sanic/conftest.py
new file mode 100644
--- /dev/null
+++ b/tests/sanic/conftest.py
@@ -0,0 +1,8 @@
+import pytest
+
+from .app import create_app
+
+
+@pytest.fixture
+def sanic_client():
+    yield create_app()
diff --git a/tests/sanic/test_upload.py b/tests/sanic/test_upload.py
new file mode 100644
--- /dev/null
+++ b/tests/sanic/test_upload.py
@@ -0,0 +1,132 @@
+OPERATIONS_FIELD = (
+    "------sanic\r\n"
+    'Content-Disposition: form-data; name="operations"\r\n'
+    "\r\n"
+    "{"
+    '"query": "mutation($textFile: Upload!){readText(textFile: $textFile)}",'
+    '"variables": {"textFile": null}'
+    "}\r\n"
+)
+
+MAP_FIELD = (
+    "------sanic\r\n"
+    'Content-Disposition: form-data; name="map"\r\n'
+    "\r\n"
+    '{"textFile": ["variables.textFile"]}\r\n'
+)
+
+TEXT_FILE_FIELD = (
+    "------sanic\r\n"
+    'Content-Disposition: form-data; name="textFile"; filename="textFile.txt"\r\n'
+    "Content-Type: text/plain\r\n"
+    "\r\n"
+    "strawberry\r\n"
+)
+
+END = "------sanic--"
+
+
+def test_single_file_upload(sanic_client):
+    data = OPERATIONS_FIELD + MAP_FIELD + TEXT_FILE_FIELD + END
+    headers = {"content-type": "multipart/form-data; boundary=----sanic"}
+
+    request, response = sanic_client.test_client.post(
+        "/graphql",
+        data=data,
+        headers=headers,
+    )
+
+    assert response.status_code == 200
+    assert not response.json.get("errors")
+    assert response.json["data"]["readText"] == "strawberry"
+
+
+def test_extra_form_data_fields_are_ignored(sanic_client):
+    extra_field = (
+        "------sanic\r\n"
+        'Content-Disposition: form-data; name="extra_field"\r\n'
+        "\r\n"
+        "{}\r\n"
+    )
+
+    data = OPERATIONS_FIELD + MAP_FIELD + TEXT_FILE_FIELD + extra_field + END
+    headers = {"content-type": "multipart/form-data; boundary=----sanic"}
+
+    request, response = sanic_client.test_client.post(
+        "/graphql",
+        data=data,
+        headers=headers,
+    )
+
+    assert response.status_code == 200
+    assert not response.json.get("errors")
+
+
+def test_sending_invalid_form_data(sanic_client):
+    headers = {"content-type": "multipart/form-data; boundary=----fake"}
+    request, response = sanic_client.test_client.post(
+        "/graphql",
+        headers=headers,
+    )
+
+    assert response.status_code == 400
+    assert "No GraphQL query found in the request" in response.text
+
+
+def test_malformed_query(sanic_client):
+    operations_field = (
+        "------sanic\r\n"
+        'Content-Disposition: form-data; name="operations"\r\n'
+        "\r\n"
+        "{"
+        '"NOT_QUERY": "",'
+        '"variables": {"textFile": null}'
+        "}\r\n"
+    )
+
+    data = operations_field + MAP_FIELD + TEXT_FILE_FIELD + END
+    headers = {"content-type": "multipart/form-data; boundary=----sanic"}
+
+    request, response = sanic_client.test_client.post(
+        "/graphql",
+        data=data,
+        headers=headers,
+    )
+
+    assert response.status_code == 400
+    assert "No GraphQL query found in the request" in response.text
+
+
+def test_sending_invalid_json_body(sanic_client):
+    operations_field = (
+        "------sanic\r\n"
+        'Content-Disposition: form-data; name="operations"\r\n'
+        "\r\n"
+        "}\r\n"
+    )
+
+    data = operations_field + MAP_FIELD + TEXT_FILE_FIELD + END
+    headers = {"content-type": "multipart/form-data; boundary=----sanic"}
+
+    request, response = sanic_client.test_client.post(
+        "/graphql",
+        data=data,
+        headers=headers,
+    )
+
+    assert response.status_code == 400
+    assert "Unable to parse request body as JSON" in response.text
+
+
+def test_upload_with_missing_file(sanic_client):
+    data = OPERATIONS_FIELD + MAP_FIELD + END
+    headers = {"content-type": "multipart/form-data; boundary=----sanic"}
+
+    request, response = sanic_client.test_client.post(
+        "/graphql",
+        data=data,
+        headers=headers,
+    )
+
+    assert response.status_code == 400
+    assert "File(s) missing in form data" in response.text
diff --git a/tests/sanic/test_view.py b/tests/sanic/test_view.py
--- a/tests/sanic/test_view.py
+++ b/tests/sanic/test_view.py
@@ -1,36 +1,9 @@
-from random import random
-
-import pytest
-
 import strawberry
 from sanic import Sanic
 from strawberry.sanic.views import GraphQLView as BaseGraphQLView
 from strawberry.types import ExecutionResult, Info
 
-
-def create_app(**kwargs):
-    @strawberry.type
-    class Query:
-        hello: str = "strawberry"
-
-    schema = strawberry.Schema(query=Query)
-
-    class GraphQLView(BaseGraphQLView):
-        def get_root_value(self):
-            return Query()
-
-    app = Sanic("test-app-" + str(random()))
-
-    app.add_route(
-        GraphQLView.as_view(schema=schema, graphiql=kwargs.get("graphiql", True)),
-        "/graphql",
-    )
-    return app
-
-
-@pytest.fixture
-def sanic_client():
-    yield create_app()
+from .app import create_app
 
 
 def test_graphql_query(sanic_client):