Repository: encode/starlette

Issue:
Support for background tasks for `GraphQLApp`
Since Response provided by starlette take a background argument, Any plans to add support for running a list of background tasks after the response has been sent in the case of `GraphQLApp`?

A naive implementation looks like this

```python
class Holder(object):
    def __init__(self):
        self._list = []

    def add(self, function, *args, **kwargs):
        func = functools.partial(function, *args, **kwargs)
        self._list.append(func)

    def clean(self):
        self._list = []

    def run_all_tasks(self):
        for i in self._list:
            i()
```

```python
class EnhancedGraphqlApp(GraphQLApp):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.holder = Holder()
        
    async def execute(self, query, variables=None, operation_name=None):  # type: ignore
        print("Started")
        print(self.holder._list)
        if self.is_async:
            result = await self.schema.execute(
                query,
                variables=variables,
                operation_name=operation_name,
                context={"holder": self.holder},
                executor=self.executor,
                return_promise=True,
            )
        else:
            func = functools.partial(
                self.schema.execute,
                context={"holder": self.holder},
                variables=variables,
                operation_name=operation_name,
            )
            loop = asyncio.get_event_loop()
            result = await loop.run_in_executor(None, func, query)
        return result

    def run_tasks(self):
        for i in self.holder._list:
            i()
        self.holder.clean()

    async def handle_graphql(self, request: Request) -> Response:
        # existing implementation
        ... 
        tasks = BackgroundTask(self.run_tasks)
        return JSONResponse(response_data, background=tasks, status_code=status_code)
```

And usage in the Schema class would be just like how context is used generally

```python
def hello(name):
    print(f"My name is {name}")

class Query(object):
    hello = graphene.String(name=graphene.String(default_value="stranger"))
    alls = graphene.List(AllModel)

    async def resolve_alls(self, info):
        rresult = await async_print_all_models()
        holder = info.context.get('holder')
        holder.add(hello,"James")
        return [AllModel(name=x.name) for x in rresult]
```

Comments:

Patch:
diff --git a/docs/background.md b/docs/background.md
index de7135f..d27fa65 100644
--- a/docs/background.md
+++ b/docs/background.md
@@ -6,6 +6,8 @@ the response has been sent.
 
 ### Background Task
 
+Used to add a single background task to a response.
+
 Signature: `BackgroundTask(func, *args, **kwargs)`
 
 ```python
@@ -27,3 +29,35 @@ async def signup(request):
 async def send_welcome_email(to_address):
     ...
 ```
+
+### BackgroundTasks
+
+Used to add multiple background tasks to a response.
+
+Signature: `BackgroundTasks(tasks=[])`
+
+```python
+from starlette.applications import Starlette
+from starlette.responses import JSONResponse
+from starlette.background import BackgroundTasks
+
+app = Starlette()
+
+@app.route('/user/signup', methods=['POST'])
+async def signup(request):
+    data = await request.json()
+    username = data['username']
+    email = data['email']
+    tasks = BackgroundTasks()
+    tasks.add_task(send_welcome_email, to_address=email)
+    tasks.add_task(send_admin_notification, username=username)
+    message = {'status': 'Signup successful'}
+    return JSONResponse(message, background=tasks)
+
+async def send_welcome_email(to_address):
+    ...
+
+async def send_admin_notification(username):
+    ...
+
+```
diff --git a/docs/graphql.md b/docs/graphql.md
index 16f9188..785a837 100644
--- a/docs/graphql.md
+++ b/docs/graphql.md
@@ -41,6 +41,27 @@ class Query(graphene.ObjectType):
         return request.headers.get("User-Agent", "<unknown>")
 ```
 
+## Adding background tasks
+
+You can add background tasks to run once the response has been sent.
+
+```python
+class Query(graphene.ObjectType):
+    user_agent = graphene.String()
+
+    def resolve_user_agent(self, info):
+        """
+        Return the User-Agent of the incoming request.
+        """
+        user_agent = request.headers.get("User-Agent", "<unknown>")
+        background = info.context["background"]
+        background.add_task(log_user_agent, user_agent=user_agent)
+        return user_agent
+
+async def log_user_agent(user_agent):
+    ...
+```
+
 ## Sync or Async executors
 
 If you're working with a standard ORM, then just use regular function calls for
diff --git a/starlette/background.py b/starlette/background.py
index dbe7592..b2a3cfe 100644
--- a/starlette/background.py
+++ b/starlette/background.py
@@ -18,3 +18,18 @@ class BackgroundTask:
             await self.func(*self.args, **self.kwargs)
         else:
             await run_in_threadpool(self.func, *self.args, **self.kwargs)
+
+
+class BackgroundTasks(BackgroundTask):
+    def __init__(self, tasks: typing.Sequence[BackgroundTask] = []):
+        self.tasks = list(tasks)
+
+    def add_task(
+        self, func: typing.Callable, *args: typing.Any, **kwargs: typing.Any
+    ) -> None:
+        task = BackgroundTask(func, *args, **kwargs)
+        self.tasks.append(task)
+
+    async def __call__(self) -> None:
+        for task in self.tasks:
+            await task()
diff --git a/starlette/graphql.py b/starlette/graphql.py
index f01ac81..3d95b1d 100644
--- a/starlette/graphql.py
+++ b/starlette/graphql.py
@@ -3,6 +3,7 @@ import json
 import typing
 
 from starlette import status
+from starlette.background import BackgroundTasks
 from starlette.concurrency import run_in_threadpool
 from starlette.requests import Request
 from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response
@@ -73,7 +74,10 @@ class GraphQLApp:
                 status_code=status.HTTP_400_BAD_REQUEST,
             )
 
-        result = await self.execute(request, query, variables)
+        background = BackgroundTasks()
+        context = {"request": request, "background": background}
+
+        result = await self.execute(query, variables=variables, context=context)
         error_data = (
             [format_graphql_error(err) for err in result.errors]
             if result.errors
@@ -83,13 +87,14 @@ class GraphQLApp:
         status_code = (
             status.HTTP_400_BAD_REQUEST if result.errors else status.HTTP_200_OK
         )
-        return JSONResponse(response_data, status_code=status_code)
+
+        return JSONResponse(
+            response_data, status_code=status_code, background=background
+        )
 
     async def execute(  # type: ignore
-        self, request, query, variables=None, operation_name=None
+        self, query, variables=None, context=None, operation_name=None
     ):
-        context = dict(request=request)
-
         if self.is_async:
             return await self.schema.execute(
                 query,


Test patch:
diff --git a/tests/test_background.py b/tests/test_background.py
index 7a60e40..9baeb17 100644
--- a/tests/test_background.py
+++ b/tests/test_background.py
@@ -1,4 +1,4 @@
-from starlette.background import BackgroundTask
+from starlette.background import BackgroundTask, BackgroundTasks
 from starlette.responses import Response
 from starlette.testclient import TestClient
 
@@ -49,3 +49,29 @@ def test_sync_task():
     response = client.get("/")
     assert response.text == "task initiated"
     assert TASK_COMPLETE
+
+
+def test_multiple_tasks():
+    TASK_COUNTER = 0
+
+    def increment(amount):
+        nonlocal TASK_COUNTER
+        TASK_COUNTER += amount
+
+    def app(scope):
+        async def asgi(receive, send):
+            tasks = BackgroundTasks()
+            tasks.add_task(increment, amount=1)
+            tasks.add_task(increment, amount=2)
+            tasks.add_task(increment, amount=3)
+            response = Response(
+                "tasks initiated", media_type="text/plain", background=tasks
+            )
+            await response(receive, send)
+
+        return asgi
+
+    client = TestClient(app)
+    response = client.get("/")
+    assert response.text == "tasks initiated"
+    assert TASK_COUNTER == 1 + 2 + 3