The code for this example can be found on GitHub here: https://github.com/jmgraff/strawberry-graphql-chat-app.
This project builds off our previous full-stack GraphQL example. If you’re new to GraphQL and haven’t read it yet, you should check it out.
What we’re making
We’re going to make a real-time chat application using GraphQL subscriptions. GraphQL subscriptions are implemented with WebSockets and allow the server to push information to the client when it’s ready rather than the client having to ask for it.
The code for this example will be built on top of the FastAPI + Strawberry / React + URQL template here: https://github.com/jmgraff/fastapi-strawberry-urql and explained in this article.
Table of Contents
The Backend
For our backend, we’ll be using:
- uvicorn web server
- FastAPI framework
- Strawberry GraphQL package
The Schema
from uuid import uuid4
import typing
import asyncio
import strawberry
@strawberry.type
class User:
uuid: strawberry.Private[str]
name: str
@strawberry.type
class Message:
sender: User
text: str
@strawberry.type
class Room:
updated: strawberry.Private[typing.Optional[asyncio.Event]] = None
name: str = "Example Chat"
users: typing.List[User]
messages: typing.List[Message]
def get_user_by_uuid(self, uuid):
return next(user for user in self.users if user.uuid == uuid)
def add_user(self, name):
uuid = str(uuid4())
room.users.append(User(name=name, uuid=uuid))
return uuid
def add_message(self, uuid, text):
self.messages.append(Message(
sender=self.get_user_by_uuid(uuid),
text=text
))
self.updated.set()
self.updated.clear()
return self
async def wait_for_update(self):
if self.updated is None:
self.updated = asyncio.Event()
await self.updated.wait()
room = Room(users=[], messages=[])
@strawberry.type
class Query:
@strawberry.field
async def get_room() -> Room:
return room
@strawberry.type
class Subscription:
@strawberry.subscription
async def room() -> typing.AsyncGenerator[Room, None]:
while True:
yield await Query.get_room()
await room.wait_for_update()
@strawberry.type
class Mutation:
@strawberry.field
async def join(name: str) -> str:
return room.add_user(name)
@strawberry.field
async def send_message(uuid: str, text: str) -> Room:
return room.add_message(uuid, text)
backend/gql.pyOur GraphQL schema will consist of 3 custom types:
- User – this will hold a very simple representation of a User. When they connect, they’ll be given a UUID and a name
- Message – this will represent a message sent by a user. It’ll have a User object associated with it, as well as a string to represent the message itself.
- Room – this will represent the chat room. It’ll contain all the connected users and messages.
The Room Type
Let’s start with the most complicated type, the Room:
@strawberry.type
class Message:
sender: User
text: str
@strawberry.type
class Room:
updated: strawberry.Private[typing.Optional[asyncio.Event]] = None
name: str = "Example Chat"
users: typing.List[User]
messages: typing.List[Message]
def get_user_by_uuid(self, uuid):
return next(user for user in self.users if user.uuid == uuid)
def add_user(self, name):
uuid = str(uuid4())
room.users.append(User(name=name, uuid=uuid))
return uuid
def add_message(self, uuid, text):
self.messages.append(Message(
sender=self.get_user_by_uuid(uuid),
text=text
))
self.updated.set()
self.updated.clear()
return self
async def wait_for_update(self):
if self.updated is None:
self.updated = asyncio.Event()
await self.updated.wait()
room = Room(users=[], messages=[])
backend/gql.pyIn this type, we have a private field called updated
which contains an asyncio.Event
object. This is the primitive that we’ll be using to trigger real-time updates when messages are sent. It’s not instantiated by default, because we have to instantiate asyncio.Event
s in the same event loop as the rest of the application, so we do that when wait_for_update
is called if it doesn’t already exist.
It’s important to note that the event has to be created here because it’s within the asyncio
loop. If we were to create it when the Room
object is first instantiated as a default value, it would be crated before the loop starts, and not be associated with it.
In add_message
, we have one half of what makes this app real-time. Once a message has been added, we fire the updated
event, which is await
ed on by the wait_for_update
method. That method then is called from our Subscription, which we’ll get to in a little bit.
The name
, users
, and messages
fields are pretty self-explanatory.
I kept this very simple to make the example easier to follow. In a real-world application, we’d be doing a lot more error checking here. The add_user
method, for instance, should really check to make sure a user with that name doesn’t already exist, and return an error to the frontend if it does. Likewise, get_user_by_uuid
should also return an error to the frontend if it can’t find a user.
The User and Message Types
Here’s the two types that’ll represent our users and their messages:
import strawberry
@strawberry.type
class User:
uuid: strawberry.Private[str]
name: str
@strawberry.type
class Message:
sender: User
text: str
@strawberry.type
class Room:
backend/gql.pyVery simple, but one thing to note is the uuid
field in User
is private. We do this because we don’t want any user to be able to query the uuid of another user, otherwise they’d be able to masquerade as them if they were to go poking around at our API.
The Room Instance
await self.updated.wait()
room = Room(users=[], messages=[])
@strawberry.type
backend/gql.pyTo keep the example simple, we just hold all our app data in memory by instantiating a room object. Normally, we’d be storing this stuff in a database.
The Query
room = Room(users=[], messages=[])
@strawberry.type
class Query:
@strawberry.field
async def get_room() -> Room:
return room
@strawberry.type
class Subscription:
backend/gql.pyWe only have one query defined, and that’s get_room
. All it does is return the global room
instance we created in the previous section. Again, in the real world, we’d probably want to get this info from a database.
Even though we don’t really need this query since we’re just using subscriptions (explained below), it’s still required to define a query type to pass to strawberry.Schema
and it has to have at least one field in it.
The Subscription
Here’s what actually makes this app real-time:
return room
@strawberry.type
class Subscription:
@strawberry.subscription
async def room() -> typing.AsyncGenerator[Room, None]:
while True:
yield await Query.get_room()
await room.wait_for_update()
@strawberry.type
backend/gql.pyTo create a subscription
field, we have to annotate it with @straberry.subscription
. All subscription fields must return an AsyncGenerator
. In the method itself, we start up an infinite loop with the while True
, then immediately yield
the current state of the room
object. Then, we call room.wait_for_update
, which if you recall, waits for the updated
event to fire.
Unlike the polling method of constantly getting fresh data from the server, this subscription will only send new data when there’s new data to send. This is thanks to the asyncio.Event
primitive that we wait on in the room.wait_for_update
method.
The Mutations
await room.wait_for_update()
@strawberry.type
class Mutation:
@strawberry.field
async def join(name: str) -> str:
return room.add_user(name)
@strawberry.field
async def send_message(uuid: str, text: str) -> Room:
return room.add_message(uuid, text)
backend/gql.pyOur mutations are very simple. When a user “joins” the server with the join
mutation, their chosen name is added to to the room’s list of users, and their generated UUID for the current session is returned to the frontend to be used for sending messages. When a user sends a message with send_message
, they send the message itself in the text
parameter, and let the server know who they are with the uuid
parameter.
This is a very simple (and fairly insecure) way to implement some rudimentary authentication. UUIDs are hard enough to guess and trivial enough to generate that they’re good enough for this example. They function as the “client secret” so our server is reasonably sure the sender is who they say they are. Please don’t do something like this in a real application!
The Frontend
Our frontend will be built with
- ReactJS app generated with
create-react-app
- URQL GraphQL client
The URQL GraphQL client
import { createClient, defaultExchanges, subscriptionExchange } from "urql";
import { SubscriptionClient } from "subscriptions-transport-ws";
const subscriptionClient = new SubscriptionClient(
`ws://${process.env.REACT_APP_HOSTNAME}:8000/graphql`, {
reconnect: true,
lazy: true
});
export const urqlClient = createClient({
url: `http://${process.env.REACT_APP_HOSTNAME}:8000/graphql`,
exchanges: [
subscriptionExchange({
forwardSubscription: (operation) => subscriptionClient.request(operation)
}),
...defaultExchanges
]
});
frontend/src/utils.jsOur URQL client requires the NPM package subscriptions-transport-ws
to use GraphQL subscriptions. You can install it with the following command executed from the frontend
directory:
npm install subscriptions-transport-ws
ShellScriptThe SubscriptionClient
import { createClient, defaultExchanges, subscriptionExchange } from "urql";
import { SubscriptionClient } from "subscriptions-transport-ws";
const subscriptionClient = new SubscriptionClient(
`ws://${process.env.REACT_APP_HOSTNAME}:8000/graphql`, {
reconnect: true,
lazy: true
});
export const urqlClient = createClient({
frontend/src/utils.jsWe instantiate a new SubscriptionClient
object by passing in our backend server’s GraphQL endpoint, as well as an object of options.
Don’t get confused by the process.env.REACT_APP_HOSTNAME
; that is a special environment variable that create-react-app
can access at build time, which was export
ed in our Makefile. For more info, check out our full-stack GraphQL example that we based this project on.
The Subscription Exchange
});
export const urqlClient = createClient({
url: `http://${process.env.REACT_APP_HOSTNAME}:8000/graphql`,
exchanges: [
subscriptionExchange({
forwardSubscription: (operation) => subscriptionClient.request(operation)
}),
...defaultExchanges
]
});
frontend/src/utils.jsTo add our subscription client to our app, we have to put it in a subscriptionExchange
and add it to the exchanges
list in our URQL config object. If we forget to do this, we’ll get an error stating that nothing has handled the “subscription” operation.
The Default Exchanges
}),
...defaultExchanges
]
});
frontend/src/utils.jsThen, below the subscription exchange, we simply “splat” the rest of the default exchanges.
The Room Component
Our Room component will maintain a real-time list of messages in the chat room:
import { useSubscription } from "urql";
import "./Room.css";
const ROOM = `
subscription {
room {
name
messages {
sender {
name
}
text
}
}
}
`;
function Message({sender, text}) {
return (
<div className="message">
<div className="sender">{sender}:</div>
<div className="text">{text}</div>
</div>
);
}
export default function Room() {
const [{data, fetching}] = useSubscription({query: ROOM}, (_, data) => data);
if (fetching) {
return (
<h1>Loading...</h1>
);
} else {
let messages = [];
data?.room.messages.forEach((message, index) => {
messages.push(
<Message
key={index}
sender={message.sender.name}
text={message.text}
className="message"
/>
);
});
return (
<div>
<h1>{data?.room.name}</h1>
<div id="messages">
<div>{messages}</div>
</div>
</div>
);
}
}
frontend/src/Room.jsNote that we assign the index of the forEach
loop as a key
prop to the <Message>
components above. This is done to prevent a warning about list elements needing unique key
props to identify them.
The Subscription GraphQL Query
import "./Room.css";
const ROOM = `
subscription {
room {
name
messages {
sender {
name
}
text
}
}
}
`;
function Message({sender, text}) {
frontend/src/Room.jsTaking a closer look at the ROOM
subscription GraphQL query, we can see it’s almost the same as a regular query. The difference is, instead of being executed once when the component mounts, it’ll maintain a WebSocket connection to the server and receive push updates as messages come in. For the fields we want, we’re grabbing the name of the room, each message sender’s name, and the message itself.
The useSubscription Call
}
export default function Room() {
const [{data, fetching}] = useSubscription({query: ROOM}, (_, data) => data);
if (fetching) {
frontend/src/Room.jsWhen we use the subscription, we have to also pass in a reducer function, which is the arrow function you see after where we specify the ROOM
query. If we were being really efficient, we’d take apart the data
returned from the server and only use the new messages. But, to keep things simple, we just return the whole object every time.
The Message Form
The message form is how we’ll send messages to the server. It’s also our log-in form where we’ll set our username and receive a UUID if we don’t have one already.
import { useState } from "react";
import { useMutation } from "urql";
const SEND_MESSAGE = `
mutation ($uuid: String!, $text: String!) {
sendMessage(uuid: $uuid, text: $text) {
name
}
}
`;
const JOIN = `
mutation ($name: String!) {
join(name: $name)
}
`;
export default function Message() {
const [{fetching: sendMessageFetching}, sendMessage] = useMutation(SEND_MESSAGE);
const [{fetching: useMutationFetching, data}, join] = useMutation(JOIN);
const fetching = sendMessageFetching || useMutationFetching;
const [message, setMessage] = useState("");
const [name, setName] = useState("");
const handleSendMessage = (ee) => {
ee.preventDefault();
sendMessage({uuid: data.join, text: message});
setMessage("");
};
const handleJoin = (ee) => {
ee.preventDefault();
join({name});
setName("");
};
if (!data?.join) {
return (
<form onSubmit={handleJoin}>
<input
type="text"
value={name}
onChange={(ee) => setName(ee.target.value)}
disabled={fetching}
/>
<button type="submit">Join</button>
</form>
);
} else {
return (
<form onSubmit={handleSendMessage}>
<input
type="text"
value={message}
onChange={(ee) => setMessage(ee.target.value)}
disabled={fetching}
/>
<button type="submit">Submit</button>
</form>
);
}
}
frontend/src/MessageForm.jsThe sendMessage Mutation
import { useMutation } from "urql";
const SEND_MESSAGE = `
mutation ($uuid: String!, $text: String!) {
sendMessage(uuid: $uuid, text: $text) {
name
}
}
`;
const JOIN = `
frontend/src/MessageForm.jsLike we saw in the backend mutations, this takes 2 arguments:
- the uuid that was generated when we joined (explained in the next section)
- the text of the message we want to send
Since this mutation returns the Room
again, we have to select something from it, so we just grab the name
.
The join Mutation
;
const JOIN = `
mutation ($name: String!) {
join(name: $name)
}
`;
export default function Message() {
frontend/src/MessageForm.jsThis is the mutation we execute when we want to join the room. All it takes is a name, and it’ll return our uuid. Since the UUID is a string, which is a scalar, we don’t have to select any sub-fields. We can just access it from data.join
.
Joining the Room
export default function Message() {
const [{fetching: sendMessageFetching}, sendMessage] = useMutation(SEND_MESSAGE);
const [{fetching: useMutationFetching, data}, join] = useMutation(JOIN);
const fetching = sendMessageFetching || useMutationFetching;
const [message, setMessage] = useState("");
const [name, setName] = useState("");
const handleSendMessage = (ee) => {
ee.preventDefault();
sendMessage({uuid: data.join, text: message});
setMessage("");
};
const handleJoin = (ee) => {
ee.preventDefault();
join({name});
setName("");
};
if (!data?.join) {
return (
<form onSubmit={handleJoin}>
<input
type="text"
value={name}
onChange={(ee) => setName(ee.target.value)}
disabled={fetching}
/>
<button type="submit">Join</button>
</form>
);
} else {
return (
<form onSubmit={handleSendMessage}>
<input
type="text"
value={message}
onChange={(ee) => setMessage(ee.target.value)}
disabled={fetching}
/>
<button type="submit">Submit</button>
</form>
);
}
}
frontend/src/MessageForm.jsAbove you can see highlighted the logic we’re going to be using to join the room. If we don’t already have a UUID (which we get from data.join
), we will render the form to set our username. When we submit this form, we’ll execute the join
mutation and get our UUID.
Sending a Message
export default function Message() {
const [{fetching: sendMessageFetching}, sendMessage] = useMutation(SEND_MESSAGE);
const [{fetching: useMutationFetching, data}, join] = useMutation(JOIN);
const fetching = sendMessageFetching || useMutationFetching;
const [message, setMessage] = useState("");
const [name, setName] = useState("");
const handleSendMessage = (ee) => {
ee.preventDefault();
sendMessage({uuid: data.join, text: message});
setMessage("");
};
const handleJoin = (ee) => {
ee.preventDefault();
join({name});
setName("");
};
if (!data?.join) {
return (
<form onSubmit={handleJoin}>
<input
type="text"
value={name}
onChange={(ee) => setName(ee.target.value)}
disabled={fetching}
/>
<button type="submit">Join</button>
</form>
);
} else {
return (
<form onSubmit={handleSendMessage}>
<input
type="text"
value={message}
onChange={(ee) => setMessage(ee.target.value)}
disabled={fetching}
/>
<button type="submit">Submit</button>
</form>
);
}
}
frontend/src/MessageForm.jsOnce we’ve officially joined the room, we render the form to send a message. When its submitted, we pass in our uuid we got from joining, along with our message.
The Finished Product
And here’s what the final product looks like:
Bring back memories of AOL instant messenger?
Conclusion
The key to real-time GraphQL applications is requests. Rather than having the frontend poll for new information at regular intervals, requests allow the server to push information to the client only when it’s available. Implementation of GraphQL requests is very straightforward with the Strawberry GraphQL framework.
John is a professional software engineer who has been solving problems with code for 15+ years. He has experience with full stack web development, container orchestration, mobile development, DevOps, Windows and Linux kernel development, cybersecurity, and reverse engineering. In his spare time, he’s researching the potential business applications of AI.