Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for multipart subscriptions #3076

Merged
merged 68 commits into from
Aug 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
d3f3ecd
POC for multipart subscriptions
patrick91 Sep 6, 2023
865da3f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 6, 2023
3f6b7cb
Fix name
patrick91 Sep 18, 2023
1f24cc0
Progress
patrick91 Sep 18, 2023
93e4041
WIP
patrick91 Sep 19, 2023
8ecfae7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 19, 2023
8ca0118
Make test pass with django async
patrick91 Oct 7, 2023
afc4556
Attempt with FastAPI, type fixes
patrick91 Oct 7, 2023
c212554
Merge branch 'main' into feature/multipart
patrick91 Oct 13, 2023
7715736
Improve code a bit
patrick91 Oct 14, 2023
033d0bf
Improve naming
patrick91 Oct 14, 2023
33104b8
Run subs in execute
patrick91 Oct 14, 2023
b6ca9f9
Workaround httpx for now
patrick91 Oct 14, 2023
84c2413
Aiohttp support
patrick91 Oct 14, 2023
b3fced5
ASGI
patrick91 Oct 14, 2023
9821e24
Flask attempt
patrick91 Oct 14, 2023
0a3557c
Support for sanic
patrick91 Oct 14, 2023
c807e96
Wip channels
patrick91 Oct 14, 2023
8f6934e
Fix syntax
patrick91 Oct 14, 2023
6f932aa
Fix various type issues
patrick91 Oct 14, 2023
5e4e144
Pragma no cover
patrick91 Oct 15, 2023
66f327d
Initial feature table
patrick91 Oct 15, 2023
fc7ecd8
Relative urls
patrick91 Oct 15, 2023
0b73f58
Fix channels issue
patrick91 Oct 15, 2023
bec5a7a
Handle heartbeat
patrick91 Oct 20, 2023
b73dd1e
Remove type ignore
patrick91 Oct 20, 2023
0288109
Merge branch 'main' into feature/multipart
patrick91 Oct 20, 2023
52b2f2d
Add blank release file
patrick91 Oct 20, 2023
28321b8
Wrap response in payload
patrick91 Oct 20, 2023
36b121e
Update integrations
patrick91 Oct 24, 2023
da68468
Merge branch 'main' into feature/multipart
patrick91 Nov 7, 2023
b06e9be
Merge branch 'main' into feature/multipart
patrick91 Nov 8, 2023
5a4b4c4
Improve how we check for content types
patrick91 Nov 8, 2023
2df7ce5
Run channels tests, even if they are broken
patrick91 Nov 10, 2023
fe228d6
Merge branch 'main' into feature/multipart
patrick91 Jan 16, 2024
2c54b09
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 16, 2024
0ca08bb
Fix lint
patrick91 Jan 16, 2024
2b6b603
Fix import
patrick91 Jan 16, 2024
adb4660
await cancelled tasks
patrick91 Jan 16, 2024
48e52e9
Some refactoring
patrick91 Jan 16, 2024
f56149b
Remove stale comment
patrick91 Jan 16, 2024
f9d3137
update type
patrick91 Jan 16, 2024
0ea4feb
Pass request down to creat multipart response
patrick91 Jan 16, 2024
abd0394
Fix tests with lowercase headers
patrick91 Jan 16, 2024
93928e8
Get support
patrick91 Jan 16, 2024
25af971
Some release notes
patrick91 Jan 16, 2024
e41f37e
Merge branch 'main' into feature/multipart
patrick91 Jan 24, 2024
1667d19
Litestar support
patrick91 Jan 24, 2024
b15b5ac
Add docs
patrick91 Jan 26, 2024
4935201
Remove subresponse
patrick91 Jan 26, 2024
492b6af
Merge branch 'main' into feature/multipart
patrick91 Mar 1, 2024
4f5f1c4
Fix check
patrick91 Mar 2, 2024
600c5e2
Merge branch 'main' into feature/multipart
patrick91 Aug 29, 2024
7ed9e0e
Fix
patrick91 Aug 29, 2024
f0cffb1
Fix
patrick91 Aug 29, 2024
10176fa
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 29, 2024
77aef3a
Lint
patrick91 Aug 30, 2024
f9f7a8f
Merge branch 'main' into feature/multipart
patrick91 Aug 30, 2024
95e76bc
Sanic fix
patrick91 Aug 30, 2024
1dfc0aa
Merge branch 'main' into feature/multipart
patrick91 Aug 30, 2024
b811ad1
Fixes
patrick91 Aug 30, 2024
a74632f
Update release file and add tweet file
patrick91 Aug 31, 2024
5e7c136
Merge branch 'main' into feature/multipart
patrick91 Aug 31, 2024
1ba2074
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 31, 2024
bca8677
Fix tweet
patrick91 Aug 31, 2024
3bfd9b6
Update tweet
patrick91 Aug 31, 2024
259b1e5
Sub response support
patrick91 Aug 31, 2024
20e8bf7
Update docs
patrick91 Aug 31, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Release type: minor

This release adds support for multipart subscriptions in almost all[^1] of our
http integrations!

[Multipart subcriptions](https://www.apollographql.com/docs/router/executing-operations/subscription-multipart-protocol/)
are a new protocol from Apollo GraphQL, built on the
[Incremental Delivery over HTTP spec](https://github.com/graphql/graphql-over-http/blob/main/rfcs/IncrementalDelivery.md),
which is also used for `@defer` and `@stream`.

The main advantage of this protocol is that when using the Apollo Client
libraries you don't need to install any additional dependency, but in future
this feature should make it easier for us to implement `@defer` and `@stream`

Also, this means that you don't need to use Django Channels for subscription,
since this protocol is based on HTTP we don't need to use websockets.

[^1]: Flask, Chalice and the sync Django integration don't support this.
5 changes: 5 additions & 0 deletions TWEET.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
🆕 Release $version is out! Thanks to $contributor for the PR 👏

Strawberry GraphQL now supports @apollographql's multipart subscriptions! 🎉

Get it here 👉 $release_url
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ title: Strawberry docs
- [Queries](./general/queries.md)
- [Mutations](./general/mutations.md)
- [Subscriptions](./general/subscriptions.md)
- [Multipart Subscriptions](./general/multipart-subscriptions.md)
- [Why](./general/why.md)
- [Breaking changes](./breaking-changes.md)
- [Upgrading Strawberry](./general/upgrades.md)
Expand Down
27 changes: 27 additions & 0 deletions docs/general/multipart-subscriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
title: Multipart subscriptions
---

# Multipart subscriptions

Strawberry supports subscription over multipart responses. This is an
[alternative protocol](https://www.apollographql.com/docs/router/executing-operations/subscription-multipart-protocol/)
created by [Apollo](https://www.apollographql.com/) to support subscriptions
over HTTP, and it is supported by default by Apollo Client.

# Support

We support multipart subscriptions out of the box in the following HTTP
libraries:

- Django (only in the Async view)
- ASGI
- Litestar
- FastAPI
- AioHTTP
- Quart

# Usage

Multipart subscriptions are automatically enabled when using Subscription, so no
additional configuration is required.
12 changes: 12 additions & 0 deletions docs/integrations/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# integrations

WIP:

| name | Supports sync | Supports async | Supports subscriptions via websockets | Supports subscriptions via multipart HTTP | Supports file uploads | Supports batch queries |
| --------------------------- | ------------- | -------------------- | ------------------------------------- | ----------------------------------------- | --------------------- | ---------------------- |
| [django](//django.md) | ✅ | ✅ (with Async view) | ❌ (use Channels for websockets) | ✅ (From Django 4.2) | ✅ | ❌ |
| [starlette](//starlette.md) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [aiohttp](//aiohttp.md) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [flask](//flask.md) | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ |
| [channels](//channels.md) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
| [fastapi](//fastapi.md) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ filterwarnings = [
# ignoring the text instead of the whole warning because we'd
# get an error when django is not installed
"ignore:The default value of USE_TZ",
"ignore::DeprecationWarning:pydantic_openapi_schema.*",
"ignore::DeprecationWarning:graphql.*",
"ignore::DeprecationWarning:websockets.*",
"ignore::DeprecationWarning:pydantic.*",
"ignore::UserWarning:pydantic.*",
"ignore::DeprecationWarning:pkg_resources.*",
]

[tool.autopub]
Expand Down
37 changes: 35 additions & 2 deletions strawberry/aiohttp/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@
from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Callable,
Dict,
Iterable,
Mapping,
Optional,
Union,
cast,
)

Expand Down Expand Up @@ -73,11 +76,17 @@ async def get_form_data(self) -> FormData:

@property
def content_type(self) -> Optional[str]:
return self.request.content_type
return self.headers.get("content-type")


class GraphQLView(
AsyncBaseHTTPView[web.Request, web.Response, web.Response, Context, RootValue]
AsyncBaseHTTPView[
web.Request,
Union[web.Response, web.StreamResponse],
web.Response,
Context,
RootValue,
]
):
# Mark the view as coroutine so that AIOHTTP does not confuse it with a deprecated
# bare handler function.
Expand Down Expand Up @@ -180,5 +189,29 @@ def create_response(

return sub_response

async def create_multipart_response(
self,
request: web.Request,
stream: Callable[[], AsyncGenerator[str, None]],
sub_response: web.Response,
) -> web.StreamResponse:
response = web.StreamResponse(
status=sub_response.status,
headers={
**sub_response.headers,
"Transfer-Encoding": "chunked",
"Content-type": "multipart/mixed;boundary=graphql;subscriptionSpec=1.0,application/json",
},
)

await response.prepare(request)

async for data in stream():
await response.write(data.encode())

await response.write_eof()

return response


__all__ = ["GraphQLView"]
25 changes: 24 additions & 1 deletion strawberry/asgi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from typing import (
TYPE_CHECKING,
Any,
AsyncIterator,
Callable,
Mapping,
Optional,
Sequence,
Expand All @@ -14,7 +16,12 @@

from starlette import status
from starlette.requests import Request
from starlette.responses import HTMLResponse, PlainTextResponse, Response
from starlette.responses import (
HTMLResponse,
PlainTextResponse,
Response,
StreamingResponse,
)
from starlette.websockets import WebSocket

from strawberry.asgi.handlers import (
Expand Down Expand Up @@ -213,3 +220,19 @@ def create_response(
response.status_code = sub_response.status_code

return response

async def create_multipart_response(
self,
request: Request | WebSocket,
stream: Callable[[], AsyncIterator[str]],
sub_response: Response,
) -> Response:
return StreamingResponse(
stream(),
status_code=sub_response.status_code,
headers={
**sub_response.headers,
"Transfer-Encoding": "chunked",
"Content-type": "multipart/mixed;boundary=graphql;subscriptionSpec=1.0,application/json",
},
)
63 changes: 49 additions & 14 deletions strawberry/channels/handlers/http_handler.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
"""GraphQLHTTPHandler.

A consumer to provide a graphql endpoint, and optionally graphiql.
"""

from __future__ import annotations

import dataclasses
import json
import warnings
from functools import cached_property
from io import BytesIO
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Union
from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Callable,
Dict,
Mapping,
Optional,
Union,
)
from typing_extensions import assert_never
from urllib.parse import parse_qs

from django.conf import settings
Expand Down Expand Up @@ -44,6 +49,14 @@ class ChannelsResponse:
headers: Dict[bytes, bytes] = dataclasses.field(default_factory=dict)


@dataclasses.dataclass
class MultipartChannelsResponse:
stream: Callable[[], AsyncGenerator[str, None]]
status: int = 200
content_type: str = "multipart/mixed;boundary=graphql;subscriptionSpec=1.0"
headers: Dict[bytes, bytes] = dataclasses.field(default_factory=dict)


@dataclasses.dataclass
class ChannelsRequest:
consumer: ChannelsConsumer
Expand Down Expand Up @@ -186,16 +199,28 @@ def create_response(
async def handle(self, body: bytes) -> None:
request = ChannelsRequest(consumer=self, body=body)
try:
response: ChannelsResponse = await self.run(request)
response = await self.run(request)

if b"Content-Type" not in response.headers:
response.headers[b"Content-Type"] = response.content_type.encode()

await self.send_response(
response.status,
response.content,
headers=response.headers,
)
if isinstance(response, MultipartChannelsResponse):
response.headers[b"Transfer-Encoding"] = b"chunked"
await self.send_headers(headers=response.headers)

async for chunk in response.stream():
await self.send_body(chunk.encode("utf-8"), more_body=True)

await self.send_body(b"", more_body=False)

elif isinstance(response, ChannelsResponse):
await self.send_response(
response.status,
response.content,
headers=response.headers,
)
else:
assert_never(response)
except HTTPException as e:
await self.send_response(e.status_code, e.reason.encode())

Expand All @@ -204,7 +229,7 @@ class GraphQLHTTPConsumer(
BaseGraphQLHTTPConsumer,
AsyncBaseHTTPView[
ChannelsRequest,
ChannelsResponse,
Union[ChannelsResponse, MultipartChannelsResponse],
TemporalResponse,
Context,
RootValue,
Expand Down Expand Up @@ -248,6 +273,16 @@ async def get_context(
async def get_sub_response(self, request: ChannelsRequest) -> TemporalResponse:
return TemporalResponse()

async def create_multipart_response(
self,
request: ChannelsRequest,
stream: Callable[[], AsyncGenerator[str, None]],
sub_response: TemporalResponse,
) -> MultipartChannelsResponse:
status = sub_response.status_code or 200
headers = {k.encode(): v.encode() for k, v in sub_response.headers.items()}
return MultipartChannelsResponse(stream=stream, status=status, headers=headers)

async def render_graphql_ide(self, request: ChannelsRequest) -> ChannelsResponse:
return ChannelsResponse(
content=self.graphql_ide_html.encode(), content_type="text/html"
Expand Down Expand Up @@ -302,7 +337,7 @@ def run(
request: ChannelsRequest,
context: Optional[Context] = UNSET,
root_value: Optional[RootValue] = UNSET,
) -> ChannelsResponse:
) -> ChannelsResponse | MultipartChannelsResponse:
return super().run(request, context, root_value)


Expand Down
Loading
Loading