Skip to content

Commit

Permalink
Merge branch 'switch-from-graphql-transport-ws-to-graphql-ws-subproto…
Browse files Browse the repository at this point in the history
…col'
  • Loading branch information
alexander.prokhorov committed Jul 18, 2024
2 parents 09a2ffd + 9cfdd70 commit 3e13ea3
Show file tree
Hide file tree
Showing 21 changed files with 1,368 additions and 1,044 deletions.
9 changes: 7 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,18 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

# Changelog

## [1.0.0rc7] - 2023-08-17

- Supported the most recent WebSocket sub-protocol
`graphql-transport-ws` used by Apollo. See the specification:
https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md.


## [1.0.0rc6] - 2023-05-10

- GraphQL parsing and message serialization now perform concurrently
by `sync_to_async(...,thread_sensitive=False)`.


## [1.0.0rc5] - 2023-05-05

WARNING: Release contains backward incompatible changes!
Expand All @@ -38,7 +44,6 @@ WARNING: Release contains backward incompatible changes!
`SKIP` object which is no longer the case..
- Python 3.8 compatibility brought back. Tests pass OK.


## [1.0.0rc4] - 2023-05-03

- `GraphqlWsConsumer.warn_resolver_timeout` removed to avoid mess with
Expand Down
40 changes: 20 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,8 @@ class MyGraphqlWsConsumer(channels_graphql_ws.GraphqlWsConsumer):
"""Channels WebSocket consumer which provides GraphQL API."""
schema = graphql_schema

# Uncomment to send keepalive message every 42 seconds.
# send_keepalive_every = 42
# Uncomment to send ping message every 42 seconds.
# send_ping_every = 42

# Uncomment to process requests sequentially (useful for tests).
# strict_ordering = True
Expand Down Expand Up @@ -261,25 +261,18 @@ You can start with the following GraphQL requests:
# Check there are no messages.
query read { history(chatroom: "kittens") { chatroom text sender }}

# Send a message as Anonymous.
# Send a message from your session.
mutation send { sendChatMessage(chatroom: "kittens", text: "Hi all!"){ ok }}

# Check there is a message from `Anonymous`.
# Check there is a message.
query read { history(chatroom: "kittens") { text sender } }

# Login as `user`.
mutation send { login(username: "user", password: "pass") { ok } }

# Send a message as a `user`.
mutation send { sendChatMessage(chatroom: "kittens", text: "It is me!"){ ok }}

# Check there is a message from both `Anonymous` and from `user`.
query read { history(chatroom: "kittens") { text sender } }

# Subscribe, do this from a separate browser tab, it waits for events.
# Open another browser or a new incognito window (to have another
# session cookie) subscribe to make it wait for events.
subscription s { onNewChatMessage(chatroom: "kittens") { text sender }}

# Send something again to check subscription triggers.
# Send another message from the original window and see how subscription
# triggers in the other one.
mutation send { sendChatMessage(chatroom: "kittens", text: "Something ;-)!"){ ok }}
```

Expand Down Expand Up @@ -310,12 +303,19 @@ recommended to have a look the documentation of these great projects:
- [Graphene](http://graphene-python.org/)

The implemented WebSocket-based protocol was taken from the library
[subscription-transport-ws](https://github.com/apollographql/subscriptions-transport-ws)
[graphql-ws](https://github.com/enisdenjo/graphql-ws)
which is used by the [Apollo GraphQL](https://github.com/apollographql).
Check the
[protocol description](https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md)
[protocol description](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md)
for details.

NOTE: Prior to 1.0.0rc7 the library used another protocol:
[subscription-transport-ws](https://github.com/apollographql/subscriptions-transport-ws)
(see [the protocol description](https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md)).
In fact [Apollo GraphQL](https://github.com/apollographql) has been
based on this protocol for years, but eventually has switched to a new
one, so we did this as well.


### Automatic Django model serialization

Expand Down Expand Up @@ -481,7 +481,7 @@ tracker.

To solve this problem, there is the `GraphqlWsConsumer` setting
`confirm_subscriptions` which when set to `True` will make the consumer
issue an additional `data` message which confirms the subscription
issue an additional `next` message which confirms the subscription
activation. Please note, you have to modify the client's code to make it
consume this message, otherwise it will be mistakenly considered as the
first subscription notification.
Expand Down Expand Up @@ -632,8 +632,8 @@ the `Subscription`.
To better dive in it is useful to understand in general terms how
regular request are handled. When server receives JSON from the client,
the `GraphqlWsConsumer.receive_json` method is called by Channels
routines. Then the request passes to the `_on_gql_start` method which
handles GraphQL message "START". Most magic happens there.
routines. Then the request passes to the `_on_gql_subscribe` method
which handles GraphQL message "SUBSCRIBE". Most magic happens there.


### Running tests
Expand Down
116 changes: 81 additions & 35 deletions channels_graphql_ws/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,25 +42,41 @@ class GraphqlWsClient:
This class implements only the protocol itself. The implementation
of the message delivery extracted into the separate interface
`GraphqlWsTransport`. So it is possible to use this client with
different network frameworks (e.g. Tornado, AIOHTTP).
different network frameworks, e.g. Tornado or AIOHTTP.
NOTE: The `receive` method retrieves the first response received by
backend, when used with subscriptions it may either return
subscription data or some query result. The response type must be
checked outside the client manually.
This client support two subprotocols:
- graphql-transport-ws: A recent one which Apollo supports. See
https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md.
- graphql-ws: The previous one, which is left for compatibility.
Args:
transport: The `GraphqlWsTransport` instance used to send and
receive messages over the WebSocket connection.
subprotocol: WebSocket subprotocol to use by the client. Either
"graphql-transport-ws" (default) or "graphql-ws".
"""

def __init__(self, transport: _transport.GraphqlWsTransport):
def __init__(
self,
transport: _transport.GraphqlWsTransport,
subprotocol="graphql-transport-ws",
):
"""Constructor."""
assert isinstance(
transport, _transport.GraphqlWsTransport
), "Given transport does not implement the 'GraphqlWsTransport' interface!"
self._transport = transport
self._is_connected = False
assert subprotocol in (
"graphql-transport-ws",
"graphql-ws",
), "Client supports only graphql-transport-ws and graphql-ws subprotocols!"
self._subprotocol = subprotocol

@property
def transport(self) -> _transport.GraphqlWsTransport:
Expand All @@ -72,44 +88,43 @@ def connected(self) -> bool:
"""Indicate whether client is connected."""
return self._is_connected

async def connect_and_init(self, connect_only: bool = False) -> None:
async def connect_and_init(self) -> None:
"""Establish and initialize WebSocket GraphQL connection.
1. Establish WebSocket connection.
2. Initialize GraphQL connection. Skipped if connect_only=True.
2. Initialize GraphQL connection.
"""
await self._transport.connect()
if not connect_only:
await self._transport.send({"type": "connection_init", "payload": ""})
resp = await self._transport.receive()
assert resp["type"] == "connection_ack", f"Unexpected response `{resp}`!"
await self._transport.send({"type": "connection_init", "payload": ""})
resp = await self._transport.receive()
assert resp["type"] == "connection_ack", f"Unexpected response `{resp}`!"
self._is_connected = True

# Default value for `id`, because `None` is also a valid value.
AUTO = object()

async def send(self, *, msg_id=AUTO, msg_type=None, payload=None):
"""Send GraphQL message.
If any argument is `None` it is excluded from the message.
async def receive_next(self, op_id):
"""Receive GraphQL message "next" ("data" for "graphql-ws").
Args:
msg_id: The message identifier. Automatically generated by
default.
msg_type: The message type.
payload: The payload dict.
op_id: Subscribe message ID, in response to which the
next/data message should be sent.
Returns:
The message identifier.
The `payload` field of the message received.
"""
if msg_id is self.AUTO:
msg_id = str(uuid.uuid4().hex)
message = {}
message.update({"id": msg_id} if msg_id is not None else {})
message.update({"type": msg_type} if msg_type is not None else {})
message.update({"payload": payload} if payload is not None else {})
await self._transport.send(message)
return msg_id
msg_type = "next" if self._subprotocol == "graphql-transport-ws" else "data"
return await self.receive(assert_id=op_id, assert_type=msg_type)

async def receive_complete(self, op_id):
"""Receive GraphQL complete message.
Args:
op_id: Subscribe message ID, in response
to which the complete message should be sent.
"""
return await self.receive(assert_id=op_id, assert_type="complete")

async def receive(
self, *, wait_id=None, assert_id=None, assert_type=None, raw_response=False
Expand All @@ -131,7 +146,7 @@ async def receive(
"""
while True:
response = await self._transport.receive()
if self._is_keep_alive_response(response):
if self._is_ping_pong_message(response):
continue
if wait_id is None or response["id"] == wait_id:
break
Expand All @@ -145,7 +160,7 @@ async def receive(
assert response["id"] == assert_id, "Response id != expected id!"

payload = response.get("payload", None)
if payload is not None and "errors" in payload:
if payload is not None and "errors" in payload or response["type"] == "error":
raise GraphqlWsResponseError(response)

if raw_response:
Expand Down Expand Up @@ -190,22 +205,47 @@ async def subscribe(self, query, *, variables=None, wait_confirmation=True):
await self.receive(wait_id=msg_id)
return msg_id

async def start(self, query, *, variables=None):
async def start(self, query, *, variables=None, operation_name=None, msg_id=AUTO):
"""Start GraphQL request. Responses must be checked explicitly.
Args:
query: A GraphQL string query. We `dedent` it, so you do not
have to.
variables: Dict of variables (optional).
operation_name: The name of the operation, usually should
describe what the operation does, useful for identifying
the operation and logging. Optional.
msg_id: The message identifier. Automatically generated by
default.
Returns:
The message identifier.
"""
return await self.send(
msg_type="start",
payload={"query": textwrap.dedent(query), "variables": variables or {}},
msg_type = (
"subscribe" if self._subprotocol == "graphql-transport-ws" else "start"
)
payload = {
"query": textwrap.dedent(query),
"variables": variables or {},
"operationName": operation_name,
}
if msg_id is self.AUTO:
msg_id = str(uuid.uuid4().hex)
message = {"type": msg_type, "payload": payload, "id": msg_id}
await self._transport.send(message)
return msg_id

async def complete(self, op_id):
"""Complete GraphQL request.
Args:
op_id: Operation id that should be completed.
"""
msg_type = "complete" if self._subprotocol == "graphql-transport-ws" else "stop"
message = {"type": msg_type, "id": op_id}
await self._transport.send(message)

async def finalize(self):
"""Disconnect and wait the transport to finish gracefully."""
Expand Down Expand Up @@ -260,10 +300,16 @@ async def wait_disconnect(self, timeout=None):
await self._transport.wait_disconnect(timeout)
self._is_connected = False

@staticmethod
def _is_keep_alive_response(response):
"""Check if received GraphQL response is keep-alive message."""
return response.get("type") == "ka"
def _is_ping_pong_message(self, response):
"""Check if GQL response is "ping" or "pong" ("keepalive" for
"graphql-ws" subprotocol)."""
msg_type = response.get("type")
return (
msg_type in ("ping", "pong")
and self._subprotocol == "graphql-transport-ws"
or msg_type == "ka"
and self._subprotocol == "graphql-ws"
)


class GraphqlWsResponseError(Exception):
Expand Down
Loading

0 comments on commit 3e13ea3

Please sign in to comment.