We like to use GraphQL as the API layer for building SPAs (Single Page App) written with a VueJS frontend and Django backend. Types tend to map pretty nicely to models and frontend development can reuse types and queries for efficient querying to support different UI.
A great library for making this work so nicely for us is Strawberry and is a big reason we have sponsored the project for some time now.
One downside though, especially in Django, is potential for duplication of query logic. Do you extract it all into custom managers and querysets and keep resolvers super lean? In order to keep queries efficient though, you'll probably still need to inject some queryset optimizations in your types.
We do a bit of both. We reduce duplication of annotations and subqueries by creating reusable units in annotations.py
and subqueries.py
for instance. Then we optimize our GraphQL layer. Overriding get_queryset
on types and other tricks that go beyond the scope of this post (we'll have to write one soon of all how to get the most out of Strawberry).
We have a number of processes that execute within Django background tasks that need to query some of the same data and at first we were taking care to recreate the same queryset logic. That wasn't going to last very long. Whenever you duplicate complex logic, code drift happens, and before you know it you are generating reports that don't reconcile in subtle and weird ways.
Our solution was to just query through the same GraphQL layer from Python but without going through the overhead of the request/response cycle. To do this we needed to generate a machine readable schema and then load that up in an object that would allow us to execute queries just like we were doing from the frontend.
The star of the show is this object that we tuck away in a graphql.py
module:
import os
import json
from django.conf import settings
from django.http import HttpRequest
from graphql import parse
from graphql.language.ast import OperationDefinitionNode
from strawberry.types.execution import ExecutionResult
from .api.schema import private
class GraphQLSchema:
def __init__(self):
# The path to the JSON file containing the GraphQL queries generate from yarn generate
self._path = os.path.join(settings.PROJECT_ROOT, "static/src/gql/persisted-documents.json")
self._ops_by_name = {}
self._create_named_op_map()
def _create_named_op_map(self):
assert os.path.exists(self._path), f"GraphQL file not found at {self._path}"
ops_by_name = {}
with open(self._path, encoding="utf-8") as file:
documents = json.load(file).values()
for doc in documents:
ast = parse(doc)
for d in ast.definitions:
if isinstance(d, OperationDefinitionNode) and d.name:
ops_by_name[d.name.value] = doc
self._ops_by_name = ops_by_name
def execute(self, query_name: str, context: HttpRequest = None, **variables) -> ExecutionResult:
assert query_name in self._ops_by_name, f"Query {query_name} not found"
query = self._ops_by_name[query_name]
return private.execute_sync(query, variable_values=variables, context_value=context)
schema = GraphQLSchema()
We use @graphql-codegen/cli
and this config to create assets for our frontend to use as well as a version of the schema for our GraphQLSchema
to consume:
This is our codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: 'http://localhost:8000/local-graphql/',
ignoreNoDocuments: true, // for better experience with the watcher
generates: {
'./static/src/gql/types.ts': {
plugins: ['typescript'],
config: {
useTypeImports: true,
},
},
'./static/src/gql/': {
preset: 'client',
config: {
useTypeImports: true,
},
presetConfig: {
fragmentMasking: false,
persistedDocuments: {
hashAlgorithm: 'sha256' // optional; defaults to sha1
}
},
documents: [
'static/src/compositions/data/**/*.ts',
'static/src/compositions/data/gql/**/*.gql'
],
},
'./static/src/compositions/data/': {
preset: 'near-operation-file',
presetConfig: {
folder: '__generated__',
extension: '.ts',
baseTypesPath: '../../gql/types.ts' // GENERATES '@/gql/types' as './@/gql/types'...
},
config: {
useTypeImports: true,
preResolveTypes: false,
},
plugins: [
'typescript-operations',
'typed-document-node'
],
documents: ['static/src/compositions/data/gql/**/*.gql'],
},
},
};
export default config;
Now in our Django/Python code we can execute GraphQL operations just like our frontend code does:
from .graphql import schema
data = schema.execute("ShipmentsReport", id="12345")
This has been working really well. Not only is DRYing up code like this great for maintenance it a real reduction of cognitive burden.