Repository: mirumee/ariadne

Issue:
OpenTracing plugin performs deepcopy of resolver's args, which fails when file upload for larger file is used.
OpenTracing performs deep copy of arguments passed to the resolver function when args filtering is used (eg. to hide passwords), but this apparently fails there's larger uploaded file in the args.

Potential fix would be default filter that replaces uploaded files with cheap str representation (eg. `<UploadedFile(name="test.jpg", type="image/jpeg", size=44100)>`) before custom filtering logic is ran next.


Discussion:

Resolving patch:
diff --git a/ariadne/contrib/tracing/opentracing.py b/ariadne/contrib/tracing/opentracing.py
--- a/ariadne/contrib/tracing/opentracing.py
+++ b/ariadne/contrib/tracing/opentracing.py
@@ -1,11 +1,13 @@
-from copy import deepcopy
+import cgi
+import os
 from functools import partial
 from inspect import isawaitable
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, Dict, Optional, Union
 
 from graphql import GraphQLResolveInfo
 from opentracing import Scope, Tracer, global_tracer
 from opentracing.ext import tags
+from starlette.datastructures import UploadFile
 
 from ...types import ContextValue, Extension, Resolver
 from .utils import format_path, should_trace
@@ -62,10 +64,12 @@ async def resolve(
     def filter_resolver_args(
         self, args: Dict[str, Any], info: GraphQLResolveInfo
     ) -> Dict[str, Any]:
+        args_to_trace = copy_args_for_tracing(args)
+
         if not self._arg_filter:
-            return args
+            return args_to_trace
 
-        return self._arg_filter(deepcopy(args), info)
+        return self._arg_filter(args_to_trace, info)
 
 
 class OpenTracingExtensionSync(OpenTracingExtension):
@@ -101,3 +105,34 @@ def opentracing_extension(*, arg_filter: Optional[ArgFilter] = None):
 
 def opentracing_extension_sync(*, arg_filter: Optional[ArgFilter] = None):
     return partial(OpenTracingExtensionSync, arg_filter=arg_filter)
+
+
+def copy_args_for_tracing(value: Any) -> Any:
+    if isinstance(value, dict):
+        return {k: copy_args_for_tracing(v) for k, v in value.items()}
+    if isinstance(value, list):
+        return [copy_args_for_tracing(v) for v in value]
+    if isinstance(value, (UploadFile, cgi.FieldStorage)):
+        return repr_upload_file(value)
+    return value
+
+
+def repr_upload_file(upload_file: Union[UploadFile, cgi.FieldStorage]) -> str:
+    filename = upload_file.filename
+
+    if isinstance(upload_file, cgi.FieldStorage):
+        mime_type = upload_file.type
+    else:
+        mime_type = upload_file.content_type
+
+    if upload_file.file is None and isinstance(upload_file, cgi.FieldStorage):
+        size = len(upload_file.value) if upload_file.value is not None else 0
+    else:
+        file_ = upload_file.file
+        file_.seek(0, os.SEEK_END)
+        size = file_.tell()
+        file_.seek(0)
+
+    return (
+        f"{type(upload_file)}(mime_type={mime_type}, size={size}, filename={filename})"
+    )


Test patch:
diff --git a/tests/asgi/conftest.py b/tests/asgi/conftest.py
--- a/tests/asgi/conftest.py
+++ b/tests/asgi/conftest.py
@@ -3,6 +3,7 @@
 from starlette.testclient import TestClient
 
 from ariadne.asgi import GraphQL
+from ariadne.contrib.tracing.opentracing import opentracing_extension
 
 
 @pytest.fixture
@@ -10,6 +11,19 @@ def app(schema):
     return GraphQL(schema)
 
 
+@pytest.fixture
+def app_with_tracing(schema):
+    def dummy_filter(args, _):
+        return args
+
+    return GraphQL(schema, extensions=[opentracing_extension(arg_filter=dummy_filter)])
+
+
 @pytest.fixture
 def client(app):
     return TestClient(app)
+
+
+@pytest.fixture
+def client_for_tracing(app_with_tracing):
+    return TestClient(app_with_tracing)
diff --git a/tests/asgi/snapshots/snap_test_query_execution.py b/tests/asgi/snapshots/snap_test_query_execution.py
--- a/tests/asgi/snapshots/snap_test_query_execution.py
+++ b/tests/asgi/snapshots/snap_test_query_execution.py
@@ -7,24 +7,6 @@
 
 snapshots = Snapshot()
 
-snapshots['test_query_is_executed_for_post_json_request 1'] = {
-    'data': {
-        'status': True
-    }
-}
-
-snapshots['test_complex_query_is_executed_for_post_json_request 1'] = {
-    'data': {
-        'hello': 'Hello, Bob!'
-    }
-}
-
-snapshots['test_complex_query_without_operation_name_executes_successfully 1'] = {
-    'data': {
-        'hello': 'Hello, Bob!'
-    }
-}
-
 snapshots['test_attempt_execute_complex_query_without_variables_returns_error_json 1'] = {
     'data': None,
     'errors': [
@@ -41,21 +23,22 @@
     ]
 }
 
-snapshots['test_attempt_execute_query_without_query_entry_returns_error_json 1'] = {
+snapshots['test_attempt_execute_query_with_invalid_operation_name_string_returns_error_json 1'] = {
+    'data': None,
     'errors': [
         {
             'locations': None,
-            'message': 'The query must be a string.',
+            'message': "Unknown operation named 'otherOperation'.",
             'path': None
         }
     ]
 }
 
-snapshots['test_attempt_execute_query_with_non_string_query_returns_error_json 1'] = {
+snapshots['test_attempt_execute_query_with_invalid_operation_name_type_returns_error_json 1'] = {
     'errors': [
         {
             'locations': None,
-            'message': 'The query must be a string.',
+            'message': '"[1, 2, 3]" is not a valid operation name.',
             'path': None
         }
     ]
@@ -71,22 +54,21 @@
     ]
 }
 
-snapshots['test_attempt_execute_query_with_invalid_operation_name_string_returns_error_json 1'] = {
-    'data': None,
+snapshots['test_attempt_execute_query_with_non_string_query_returns_error_json 1'] = {
     'errors': [
         {
             'locations': None,
-            'message': "Unknown operation named 'otherOperation'.",
+            'message': 'The query must be a string.',
             'path': None
         }
     ]
 }
 
-snapshots['test_attempt_execute_query_with_invalid_operation_name_type_returns_error_json 1'] = {
+snapshots['test_attempt_execute_query_without_query_entry_returns_error_json 1'] = {
     'errors': [
         {
             'locations': None,
-            'message': '"[1, 2, 3]" is not a valid operation name.',
+            'message': 'The query must be a string.',
             'path': None
         }
     ]
@@ -103,8 +85,32 @@
     'path': None
 }
 
+snapshots['test_complex_query_is_executed_for_post_json_request 1'] = {
+    'data': {
+        'hello': 'Hello, Bob!'
+    }
+}
+
+snapshots['test_complex_query_without_operation_name_executes_successfully 1'] = {
+    'data': {
+        'hello': 'Hello, Bob!'
+    }
+}
+
 snapshots['test_query_is_executed_for_multipart_form_request_with_file 1'] = {
     'data': {
         'upload': 'UploadFile'
     }
 }
+
+snapshots['test_query_is_executed_for_multipart_request_with_large_file_with_tracing 1'] = {
+    'data': {
+        'upload': 'UploadFile' 
+    }
+}
+
+snapshots['test_query_is_executed_for_post_json_request 1'] = {
+    'data': {
+        'status': True
+    }
+}
diff --git a/tests/asgi/test_query_execution.py b/tests/asgi/test_query_execution.py
--- a/tests/asgi/test_query_execution.py
+++ b/tests/asgi/test_query_execution.py
@@ -4,14 +4,13 @@
 
 from ariadne.asgi import (
     GQL_CONNECTION_ACK,
-    GQL_ERROR,
     GQL_CONNECTION_INIT,
+    GQL_ERROR,
     GQL_START,
     GraphQL,
 )
 from ariadne.types import Extension
 
-
 operation_name = "SayHello"
 variables = {"name": "Bob"}
 complex_query = """
@@ -127,8 +126,10 @@ def test_attempt_execute_subscription_with_invalid_query_returns_error_json(
         snapshot.assert_match(response["payload"])
 
 
-def test_query_is_executed_for_multipart_form_request_with_file(client, snapshot):
-    response = client.post(
+def test_query_is_executed_for_multipart_form_request_with_file(
+    client_for_tracing, snapshot
+):
+    response = client_for_tracing.post(
         "/",
         data={
             "operations": json.dumps(
@@ -145,6 +146,26 @@ def test_query_is_executed_for_multipart_form_request_with_file(client, snapshot
     snapshot.assert_match(response.json())
 
 
+def test_query_is_executed_for_multipart_request_with_large_file_with_tracing(
+    client_for_tracing, snapshot
+):
+    response = client_for_tracing.post(
+        "/",
+        data={
+            "operations": json.dumps(
+                {
+                    "query": "mutation($file: Upload!) { upload(file: $file) }",
+                    "variables": {"file": None},
+                }
+            ),
+            "map": json.dumps({"0": ["variables.file"]}),
+        },
+        files={"0": ("test_2.txt", b"\0" * 1024 * 1024)},
+    )
+    assert response.status_code == 200
+    snapshot.assert_match(response.json())
+
+
 class CustomExtension(Extension):
     async def resolve(self, next_, parent, info, **kwargs):
         value = next_(parent, info, **kwargs)
diff --git a/tests/tracing/test_opentracing.py b/tests/tracing/test_opentracing.py
--- a/tests/tracing/test_opentracing.py
+++ b/tests/tracing/test_opentracing.py
@@ -3,6 +3,7 @@
 import pytest
 from graphql import get_introspection_query
 from opentracing.ext import tags
+from starlette.datastructures import UploadFile
 
 from ariadne import graphql
 from ariadne.contrib.tracing.opentracing import (
@@ -115,3 +116,22 @@ async def test_opentracing_extension_doesnt_break_introspection(schema):
         schema, {"query": introspection_query}, extensions=[OpenTracingExtension]
     )
     assert "errors" not in result
+
+
+@pytest.mark.asyncio
+async def test_resolver_args_filter_handles_uploaded_files_from_asgi(mocker):
+    def arg_filter(args, _):
+        return args
+
+    file_size = 1024 * 1024
+    extension = OpenTracingExtension(arg_filter=arg_filter)
+    file_ = UploadFile(filename="test", content_type="text/plain")
+    await file_.write(b"\0" * file_size)
+    kwargs = {"0": file_}
+    info = mocker.Mock()
+
+    copied_kwargs = extension.filter_resolver_args(kwargs, info)
+    assert (
+        f"<class 'starlette.datastructures.UploadFile'>"
+        f"(mime_type={file_.content_type}, size={file_size}, filename={file_.filename})"
+    ) == copied_kwargs["0"]
diff --git a/tests/tracing/test_opentracing_sync.py b/tests/tracing/test_opentracing_sync.py
--- a/tests/tracing/test_opentracing_sync.py
+++ b/tests/tracing/test_opentracing_sync.py
@@ -1,3 +1,5 @@
+import cgi
+import io
 from unittest.mock import ANY, call
 
 import pytest
@@ -7,8 +9,11 @@
 from ariadne import graphql_sync as graphql
 from ariadne.contrib.tracing.opentracing import (
     OpenTracingExtensionSync as OpenTracingExtension,
+)
+from ariadne.contrib.tracing.opentracing import (
     opentracing_extension_sync as opentracing_extension,
 )
+from ariadne.contrib.tracing.opentracing import copy_args_for_tracing
 
 
 @pytest.fixture
@@ -104,3 +109,67 @@ def test_opentracing_extension_doesnt_break_introspection(schema):
         schema, {"query": introspection_query}, extensions=[OpenTracingExtension]
     )
     assert "errors" not in result
+
+
+def test_resolver_args_filter_handles_uploaded_files_from_wsgi(mocker):
+    def arg_filter(args, _):
+        return args
+
+    file_size = 1024 * 1024
+    extension = OpenTracingExtension(arg_filter=arg_filter)
+    field_storage = cgi.FieldStorage()
+    field_storage.filename = "hello.txt"
+    field_storage.type = "text/plain"
+    field_storage.file = io.BytesIO()
+    field_storage.file.write(b"\0" * file_size)
+
+    kwargs = {"0": field_storage}
+    info = mocker.Mock()
+
+    copied_kwargs = extension.filter_resolver_args(kwargs, info)
+    assert (
+        f"<class 'cgi.FieldStorage'>"
+        f"(mime_type={field_storage.type}, size={file_size}, filename={field_storage.filename})"
+    ) == copied_kwargs["0"]
+
+
+def test_resolver_args_with_uploaded_files_from_wsgi_are_copied_for_tracing():
+    storage1 = cgi.FieldStorage()
+    storage1.type = "text/plain"
+    storage1.filename = "hello"
+    storage1.value = b"111"
+
+    storage2 = cgi.FieldStorage()
+    storage2.type = "text/plain"
+    storage2.filename = "hi"
+    storage2.value = None
+
+    test_dict = {
+        "a": 10,
+        "b": [1, 2, 3, {"hehe": {"Hello": 10}}],
+        "c": storage1,
+        "d": {"ee": ["zz", [10, 10, 10], storage2]},
+    }
+    result = copy_args_for_tracing(test_dict)
+    assert {
+        "a": 10,
+        "b": [1, 2, 3, {"hehe": {"Hello": 10}}],
+        "c": (
+            f"<class 'cgi.FieldStorage'>(mime_type={storage1.type}, "
+            f"size={len(storage1.value)}, filename={storage1.filename})"
+        ),
+        "d": {
+            "ee": [
+                "zz",
+                [
+                    10,
+                    10,
+                    10,
+                ],
+                (
+                    f"<class 'cgi.FieldStorage'>(mime_type={storage2.type}, "
+                    f"size=0, filename={storage2.filename})"
+                ),
+            ],
+        },
+    } == result