Persisted Queries with Relay, Strawberry GraphQL and FastAPI
In this tutorial, we’re going to explore how to set up persisted queries with Strawberry GraphQL, FastAPI and Relay. Persisted queries improve performance by letting the client send only a unique identifier for a query to the server, which then retrieves and executes the full query. This reduces the payload size, improves caching, and enhances security.
What is Relay?
Relay is a powerful JavaScript framework developed by Facebook for managing data-fetching in React applications. It’s specifically designed to work with GraphQL and offers a streamlined way to manage queries and mutations. Relay helps optimize network requests and makes it easier to manage large applications by automatically handling caching, pagination, and data-fetching logic on the client side.
What are Persisted Queries?
Persisted queries are GraphQL queries that are stored on the server with unique identifiers (query IDs). Instead of sending the entire query each time, the client can simply send the query ID along with any variables. The server can then retrieve and execute the stored query.
This approach offers several benefits:
- Performance: Reduces the payload size, especially useful for low-bandwidth clients.
- Security: Minimizes the chance of clients sending unvalidated or unintended queries.
- Caching: Allows easier caching of frequently used queries, reducing load on the server.
Let’s dive into the setup to implement persisted queries with Strawberry GraphQL and Relay.
There are two ways to setup persisted queries with relay:
- Server Persisted queries - Run a local server that updates a query map server-side upon requests from the Relay client.
- Local Persisted queries - Generate the query map client side (using Relay) and push it to the server at compile time/ runtime.
Server Persisted Queries
In this section, we’ll implement persisted queries using a server-based method to store and manage query IDs.
Step 1: Configure Relay on the Client Side
First, let’s configure Relay in the client application. Ensure Relay is set up properly and that the Relay environment is configured. In your package.json, add the Relay configuration with the URL of your persisted query server:
{
"relay": {
"src": "./src",
"language": "typescript",
"schema": "./schema.graphql",
"persistConfig": {
"url": "http://localhost:2999",
"params": {}
}
}
}
This configuration specifies the location of the schema file and the URL for the persisted query server.
Next, create a function to fetch the queries. This function should send the query ID to the persisted query server, which will retrieve and execute the query on the server.
import { Environment, Network, RecordSource, Store } from 'relay-runtime'
function fetchQuery(operation, variables) {
return fetch('http://localhost:8000/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
document_id: operation.id, // pass the persisted query ID
variables,
}),
}).then((response) => {
return response.json()
})
}
const environment = new Environment({
network: Network.create(fetchQuery),
store: new Store(new RecordSource()),
})
This code sets up a Relay environment with a fetchQuery function that references the query by its unique ID, sending it to the server for execution.
Step 2: Run the Persist Server
On the server side, we need a way to store and retrieve these persisted queries. Here’s a Python script, run_persist_server.py
that will act as a simple server for handling and saving these queries:
import argparse
import json
from hashlib import md5
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs
class QueryMap:
def __init__(self, file_map_name: Path):
"""Initializes the QueryMap with a file path for storage."""
self.file_map_name = file_map_name
self.query_map = self._load_or_initialize_map()
def _load_or_initialize_map(self) -> dict:
"""Loads the map from file if it exists; otherwise, initializes an empty map."""
if self.file_map_name.exists():
with self.file_map_name.open("r") as file:
return json.load(file)
else:
self._flush({})
return {}
def _flush(self, data: dict = None) -> None:
"""Writes the current map to file."""
with self.file_map_name.open("w") as file:
json.dump(data or self.query_map, file)
def save_query(self, text: str) -> str:
"""Generates a unique ID for the given text and saves it in the map."""
query_id = md5(text.encode()).hexdigest()
self.query_map[query_id] = text
self._flush()
return query_id
class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
def do_POST(self):
"""Handles POST requests by saving the text query and responding with its ID."""
if self.headers.get("Content-Type") != "application/x-www-form-urlencoded":
self._send_response(400, 'Only "application/x-www-form-urlencoded" requests are supported.')
return
content_length = int(self.headers["Content-Length"])
post_data = self.rfile.read(content_length).decode()
params = parse_qs(post_data)
text = params.get("text", [None])[0]
if text is None:
self._send_response(400, "Expected `text` parameter in the POST.")
return
query_id = query_map.save_query(text)
response_data = json.dumps({"id": query_id})
self._send_response(200, response_data, content_type="application/json")
def _send_response(self, status_code: int, message: str, content_type: str = "text/plain") -> None:
"""Helper method to send a response with the given status code and message."""
self.send_response(status_code)
self.send_header("Content-Type", content_type)
self.end_headers()
self.wfile.write(message.encode())
def main():
parser = argparse.ArgumentParser(description="Run a simple HTTP server for query storage.")
parser.add_argument("--port", type=int, default=2999, help="Port to run the server on")
parser.add_argument("--host", type=str, default="127.0.0.1", help="Host to run the server on")
parser.add_argument("--file", type=str, default="query_map.json", help="File name for storing queries")
args = parser.parse_args()
port = args.port
host = args.host
file_map_name = Path(args.file)
global query_map
query_map = QueryMap(file_map_name)
server = HTTPServer((host, port), SimpleHTTPRequestHandler)
print(f"Server listening on port {port}")
server.serve_forever()
if __name__ == "__main__":
main()
NOTE
You can also store your queries in a database here
This script implements a simple HTTP server that listens for POST requests, allowing clients to submit text queries. Each query is saved with a unique ID generated from an MD5 hash of the text. The server stores these queries in a JSON file, ensuring data persistence even if the server restarts.
High-Level Breakdown:
- Query Handling and Storage: Upon receiving a POST request with a text parameter, the server:
- Generates a unique ID for the text using an MD5 hash.
- Saves this ID-text mapping in a JSON file (specified by the file argument).
- Responds with the generated ID in JSON format, enabling clients to store them.
- JSON Query Storage:
- The script loads the existing data from the JSON file at startup (if the file exists).
- New queries are appended to this file, providing a persistent storage solution.
You can run the script with the following command:
python run_persist_server.py --file query_map.json --port 2999 --host 127.0.0.1
Step 3: Create a Custom Strawberry GraphQL Schema Extension
To use persisted queries, we’ll extend our Strawberry GraphQL schema with a custom schema extension. This extension allows the server to recognize query IDs and look up the corresponding query text.
import json
from collections.abc import AsyncIterator, Iterator
# Add import for loading persisted queries
from pathlib import Path
from graphql import ExecutionResult, GraphQLError
from strawberry.extensions import SchemaExtension
class PersistedQueriesExtension(SchemaExtension):
def __init__(self, *, persisted_queries_path: Path) -> None:
self.cache: dict[str, str] = {}
with Path.open(persisted_queries_path, "r") as f:
self.cache = json.load(f)
async def on_operation(self) -> AsyncIterator[None] | Iterator[None]:
body = await self.execution_context.context.get("request").json()
document_id = body.get("document_id")
persisted_query = self.cache.get(document_id)
if persisted_query is None:
self.execution_context.result = ExecutionResult(
data=None,
errors=[
GraphQLError("Invalid query provided."),
],
)
else:
self.execution_context.query = persisted_query
yield
This extension retrieves queries from the persisted_queries_path
and stores them in a cache. When a client requests a query, it checks if the query ID is valid. If the query isn’t found, an error is returned.
Step 4: Add the Extension to Your Schema
Next, you need to add it to your Strawberry GraphQL schema. This is done by including the extension when instantiating the schema, so that the persisted query mechanism is activated for every request.
from pathlib import Path
from strawberry import Schema
from app.extensions import PersistedQueriesExtension
schema = Schema(
query=query,
mutation=mutation,
extensions=[
PersistedQueriesExtension(
persisted_queries_path=Path("query_map.json")
),
],
)
This completes the configuration, and your server is now ready to handle persisted queries.
IMPORTANT
If you are setting up persisted queries with a project that already has generated definitions, the persisted queries won't be sent to the specified URI. You need to clear the generated definitions and rerun the Relay compiler.
Local Persisted Queries
Step 1: Configure Relay on the Client Side
First, let’s configure Relay in the client application. Ensure Relay is set up properly and that the Relay environment is configured. In your package.json, add the following Relay configuration:
{
"relay": {
"src": "./src",
"language": "typescript",
"schema": "./schema.graphql",
"persistConfig": {
"file": "./query_map.json",
"algorithm": "MD5" // this can be one of MD5, SHA256, SHA1
}
}
}
This configuration specifies the location of the local persisted queries file.
Next, create a function to fetch the queries. This function should send the query ID to the persisted query server, which will retrieve and execute the query on the server.
import { Environment, Network, RecordSource, Store } from 'relay-runtime'
function fetchQuery(operation, variables) {
return fetch('http://localhost:8000/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
document_id: operation.id, // pass the persisted query ID
variables,
}),
}).then((response) => {
return response.json()
})
}
const environment = new Environment({
network: Network.create(fetchQuery),
store: new Store(new RecordSource()),
})
This code sets up a Relay environment with a fetchQuery function that references the query by its unique ID, sending it to the server for execution.
Step 2: Write a Script to Push Queries during Compile Time
Because our client and server projects are separate, a valid option is to have an additional npm run script to push the query map at compile time to a location accessible by your server:
"scripts": {
"push-queries": "node ./pushQueries.js",
"relay": "relay-compiler && npm run push-queries"
}
Here is a script that pushes the query_map.json
file in the client
directory to the query_map.json
file in the server directory.
const fs = require('fs')
const path = require('path')
// Define paths
const clientDir = path.join(__dirname, 'client')
const serverDir = path.join(__dirname, 'server')
const sourceFile = path.join(clientDir, 'query_map.json')
const destFile = path.join(serverDir, 'query_map.json')
// Check if source file exists
fs.access(sourceFile, fs.constants.F_OK, (err) => {
if (err) {
console.error(`Source file ${sourceFile} does not exist.`)
process.exit(1)
} else {
// Copy the file
fs.copyFile(sourceFile, destFile, (err) => {
if (err) {
console.error(`Error copying file: ${err}`)
process.exit(1)
} else {
console.log(`Copied ${sourceFile} to ${destFile}`)
}
})
}
})
With this in place, every time the relay compiler runs, changes can be synced with the server.
NOTE
You could also push to another git repository (where your server resides, if applicable) or store the queries in a database here.
Step 3: Create a Custom Strawberry GraphQL Schema Extension
To use persisted queries, we’ll extend our Strawberry GraphQL schema with a custom schema extension. This extension allows the server to recognize query IDs and look up the corresponding query text.
import json
from collections.abc import AsyncIterator, Iterator
# Add import for loading persisted queries
from pathlib import Path
from graphql import ExecutionResult, GraphQLError
from strawberry.extensions import SchemaExtension
class PersistedQueriesExtension(SchemaExtension):
def __init__(self, *, persisted_queries_path: Path) -> None:
self.cache: dict[str, str] = {}
with Path.open(persisted_queries_path, "r") as f:
self.cache = json.load(f)
async def on_operation(self) -> AsyncIterator[None] | Iterator[None]:
body = await self.execution_context.context.get("request").json()
document_id = body.get("document_id")
persisted_query = self.cache.get(document_id)
if persisted_query is None:
self.execution_context.result = ExecutionResult(
data=None,
errors=[
GraphQLError("Invalid query provided."),
],
)
else:
self.execution_context.query = persisted_query
yield
This extension retrieves queries from the persisted_queries_path
and stores them in a cache. When a client requests a query, it checks if the query ID is valid. If the query isn’t found, an error is returned.
Step 4: Add the Extension to Your Schema
Next, you need to add it to your Strawberry GraphQL schema. This is done by including the extension when instantiating the schema, so that the persisted query mechanism is activated for every request.
from pathlib import Path
from strawberry import Schema
from app.extensions import PersistedQueriesExtension
schema = Schema(
query=query,
mutation=mutation,
extensions=[
PersistedQueriesExtension(
persisted_queries_path=Path("query_map.json")
),
],
)
This completes the configuration, and your server is now ready to handle persisted queries.
Conclusion
You’ve now set up Strawberry GraphQL with Relay and enabled persisted queries! This approach optimizes performance by allowing your client application to reference and execute stored queries via unique identifiers. By setting up persisted queries, you can significantly reduce the payload size, enhance caching, and secure your GraphQL queries.
Consider exploring other features of Strawberry and Relay to further customize and optimize your GraphQL applications.