Supporting StreamFields, Snippets and Images in a Wagtail GraphQL API
GraphQL is a promising new technology. Yet implementing it in Wagtail comes with its fair share of challenges. This article helps you to overcome them.
I’m very new to the Wagtail world and just as new to Python and Django for that matter. My roots and experiences lie in JavaScript and my toolchain is based on NodeJS. So, when it came to building a prototype with Wagtail to explore its capabilities, one of the first things I did was investigating how I could combine it with a NodeJS-based frontend. GatsbyJS is currently a super hyped technology in the JavaScript community and I wanted to try it out alongside Wagtail. GatsbyJS uses GraphQL by default to fetch data from a server. Luckily, the Wagtail blog already had a tutorial on how to implement a GraphQL interface in Wagtail.
If you follow the instructions in the blog post everything works like a charm and you’re quickly getting your desired output in GraphiQL. However, when I followed the instructions to implement it in the prototype I was building, I only got error messages. This is because the blog post forgets to provide a solution for one of Wagtail’s prominent features: StreamField.
The described solution is based on graphene
and graphene-django
. But the StreamField is a Wagtail specific field and therefore not covered by graphene-django
. Various Google searches didn’t bring up an easy, straightforward solution either. So, with this article I want to change that and provide solutions that you can use to implement GraphQL into your Wagtail project.
Adding a StreamField to our Blog
I’m starting where the GraphQL Wagtail blog post left off. So, if you want to follow along read that blog post first.
In order to develop a solution for StreamFields, first we need to create one. Therefore I’m replacing the RichTextField
in the BlogPage
model with a StreamField. Stop the server and make these changes.
# mysite/blog/models.py ... # Add imports for StreamField, blocks and StreamFieldPanel from wagtail.core.fields import RichTextField, StreamField from wagtail.core import blocks from wagtail.admin.edit_handlers import FieldPanel, StreamFieldPanel ... class BlogPage(Page): ... body = StreamField([ ('heading', blocks.CharBlock(classname="full title")), ('paragraph', blocks.RichTextBlock()), ]) ... content_panels = Page.content_panels + [ ... StreamFieldPanel('body'), ]
Run the commands to make the migrations and apply them. Then, start the server again.
$ python manage.py makemigrations $ python manage.py migrate $ python manage.py runserver
Edit your blog page or create a new one, add some content especially in the newly added StreamField and publish it.
If you go to your GraphiQL interface and reload the page with the very same query you had before you'll get an error output.
query articles { articles { id title date intro body } }
The error says:
Don't know how to convert the Django field blog.BlogPage.body (<class 'wagtail.core.fields.StreamField'>)
Adding a generic StreamField converter
The error is thrown because nobody taught graphene-django
how to deal with StreamFields. GitHub user Patrick Arminio came up with a solution that we’re about to implement now.
Create a new file in the api
directory and name it graphene_wagtail.py
:
# mysite/api/graphene_wagtail.py # Taken from https://github.com/patrick91/wagtail-ql/blob/master/backend/graphene_utils/converter.py and slightly adjusted from wagtail.core.fields import StreamField from graphene.types import Scalar from graphene_django.converter import convert_django_field class GenericStreamFieldType(Scalar): @staticmethod def serialize(stream_value): return stream_value.stream_data @convert_django_field.register(StreamField) def convert_stream_field(field, registry=None): return GenericStreamFieldType( description=field.help_text, required=not field.null )
For now, add the following import statement to schema.py
; we’re gonna change it later:
# mysite/api/schema.py ... from api import graphene_wagtail
If you reload your GraphiQL interface, everything should work now and your StreamField contents should be included as JSON.
What did we do?
The function convert_stream_field
was registered to convert StreamFields to a respective GraphQL representation. It returns a GenericStreamFieldType
which in turn inherits from the Scalar
class. A Scalar is a data type defined in GraphQL and the Scalar class is graphene’s implementation of that type. The serialize
method receives the StreamField’s value as an argument and returns the stream_data
attribute which is its JSON representation.
So, are we done now? Did we find the perfect solution? Not so fast.
Getting more complex: Adding snippets and images
Let’s say, recipes are constantly featured in our blog. So, to have a central way of entering a recipe we’ll write a model for it and register it as a snippet.
# mysite/blog/models.py ... from wagtail.images.edit_handlers import ImageChooserPanel from wagtail.snippets.models import register_snippet from wagtail.snippets.blocks import SnippetChooserBlock @register_snippet class Recipe(models.Model): title = models.CharField(max_length=255) image = models.ForeignKey( 'wagtailimages.Image', null=True, blank=True, on_delete=models.SET_NULL, related_name='+' ) ingredients = StreamField([ ('ingredient', blocks.StructBlock([ ('name', blocks.CharBlock()), ('quantity', blocks.DecimalBlock()), ('unit', blocks.ChoiceBlock(choices=[ ('none', '(no unit)'), ('g', 'Grams (g)'), ('ml', 'Millilitre (ml)'), ('tsp', 'Teaspoon (tsp.)'), ('tbsp', 'Tablespoon (tbsp.)'), ])) ])) ]) instructions = StreamField([ ('instruction', blocks.TextBlock()), ]) panels = [ FieldPanel('title'), ImageChooserPanel('image'), StreamFieldPanel('ingredients'), StreamFieldPanel('instructions'), ] def __str__(self): return self.title
We then add a SnippetChooserBlock
to our BlogPage
’s body to embed the recipe in the body of a blog page.
# mysite/blog/models.py ... class BlogPage(Page): ... body = StreamField([ ('heading', blocks.CharBlock(classname="full title")), ('paragraph', blocks.RichTextBlock()), ('recipe', SnippetChooserBlock(Recipe)), # Add this line ])
Make and apply the migrations:
$ python manage.py makemigrations $ python manage.py migrate $ python manage.py runserver
Create a recipe snippet and embed it in a blog page.
Go to your GraphiQL interface and run the query. You won’t get any errors, but the result is disappointing.
This is what we get as our recipe:
{ "id": "8d49c2fa-1d5d-4c9e-a2c7-f855a7966867", "type": "recipe", "value": 1 }
The recipe is only included as a reference id. That way if we wanted to get the recipe’s data we would need to create an additional query. This breaks with one of GraphQL’s central premises: You shouldn’t need to send multiple queries to get the data you want.
How do we solve this? The StreamField converter we implemented is generic and doesn’t know how to represent nested data. If we extended our converter to analyze a StreamField’s value and recursively retrieve nested data this would be rather complex first of all, but it also could potentially lead to giant blobs of data. This would then break another of GraphQL’s premises: you should only get the data you need.
Using subqueries per block type
The solution is to explicitly define what data we want from the StreamField value in the query. This is possible with GraphQL’s Inline Fragments. In order to support Inline Fragments the GraphQL representation of our StreamField value has to be a Union type.
# mysite/api/schema.py ... # Add these imports from blog.models import BlogPage, Recipe from graphene.types.generic import GenericScalar ... class RecipeNode(DjangoObjectType): class Meta: model = Recipe class ParagraphBlock(graphene.ObjectType): value = GenericScalar() class HeadingBlock(graphene.ObjectType): value = GenericScalar() class RecipeBlock(graphene.ObjectType): value = GenericScalar() recipe = graphene.Field(RecipeNode) def resolve_recipe(self, info): return Recipe.objects.get(id=self.value) class BlogPageBody(graphene.Union): class Meta: types = (ParagraphBlock, HeadingBlock, RecipeBlock) class ArticleNode(DjangoObjectType): body = graphene.List(BlogPageBody) class Meta: model = BlogPage only_fields = ['id', 'title', 'date', 'intro'] def resolve_body(self, info): repr_body = [] for block in self.body.stream_data: block_type = block.get('type') value = block.get('value') if block_type == 'paragraph': repr_body.append(ParagraphBlock(value=value)) elif block_type == 'heading': repr_body.append(HeadingBlock(value=value)) elif block_type == 'recipe': repr_body.append(RecipeBlock(value=value)) return repr_body
Now, we can define subqueries per block type of the StreamField value:
query articles { articles { body { ... on ParagraphBlock { value } ... on HeadingBlock { value } ... on RecipeBlock { recipe { title ingredients instructions } } } } }
What did we do?
Instead of just returning the JSON representation of the StreamField value we implemented a list of a UnionType (BlogPageBody
) for our body
field. The field’s value can now either be a ParagraphBlock
, HeadingBlock
or RecipeBlock
. A resolver function for that field (resolve_body
) iterates over the list of blocks from our StreamField value and maps each block to its respective graphene implementation.
In the query we define Inline Fragments to express which kind of data we want to get from which kind of block.
The RecipeBlock
adds another field that it resolves with the associated recipe. This recipe object is then represented by the RecipeNode
object type. The Recipe
model itself has two StreamField fields. These are resolved by the default converter that we registered in the beginning.
Let’s start a factory
This solution is specific to the BlogPage’s body field. We would need to repeat that for the StreamFields in the Recipe
model and for all other StreamFields as well. It makes sense to implement an abstraction at this point.
# mysite/api/graphene_wagtail.py ... # Add these import statements import graphene from graphene.types.generic import GenericScalar # We're creating a fallback / default ObjectType at this point class DefaultStreamBlock(graphene.ObjectType): block_type = graphene.String() value = GenericScalar() # This is our factory function # Pass in kwargs with the block's name as the # keyword and the graphene type as its value def create_stream_field_type(field_name, **kwargs): block_type_handlers = kwargs.copy() class Meta: types = (DefaultStreamBlock, ) + tuple( block_type_handlers.values()) # This is where we generate the UnionType from the kwargs # Different graphene types can't have the same name, so we're # generating this class dynamically StreamFieldType = type( f"{string.capwords(field_name, sep='_').replace('_', '')}Type", (graphene.Union,), dict(Meta=Meta)) def convert_block(block): block_type = block.get('type') value = block.get('value') if block_type in block_type_handlers: handler = block_type_handlers.get(block_type) if isinstance(value, dict): return handler(value=value, block_type=block_type, **value) else: return handler(value=value, block_type=block_type) else: return DefaultStreamBlock(value=value, block_type=block_type) # We also generate the resolver function for the field def resolve_field(self, info): field = getattr(self, field_name) return [convert_block(block) for block in field.stream_data] return (graphene.List(StreamFieldType), resolve_field)
With create_stream_field_type
we now have a factory function that generates our graphene UnionType
representation and the resolver function.
We can refactor our schema.py
and use it there.
# mysite/api/schema.py ... # Change the graphene_wagtail import to this: from .graphene_wagtail import DefaultStreamBlock, create_stream_field_type class RecipeNode(DjangoObjectType): class Meta: model = Recipe # Inherit from DefaultStreamBlock instead of graphene.ObjectType class ParagraphBlock(DefaultStreamBlock): pass class HeadingBlock(DefaultStreamBlock): pass class RecipeBlock(DefaultStreamBlock): recipe = graphene.Field(RecipeNode) def resolve_recipe(self, info): return Recipe.objects.get(id=self.value) class ArticleNode(DjangoObjectType): # create_stream_field_type returns two values: # 1. The graphene field representation with the UnionType # 2. A resolver function that maps each block to its resp. graphene class # Destructure the values and give them the correct names (body, resolve_body) = create_stream_field_type( 'body', paragraph=ParagraphBlock, heading=HeadingBlock, recipe=RecipeBlock) class Meta: model = BlogPage only_fields = ['id', 'title', 'date', 'intro']
With this factory function it's easy to implement it in RecipeNode
as well.
# mysite/api/schema.py ... class IngredientBlock(DefaultStreamBlock): name = graphene.String() quantity = graphene.Float() unit = graphene.String() class RecipeNode(DjangoObjectType): class Meta: model = Recipe (ingredients, resolve_ingredients) = create_stream_field_type( 'ingredients', ingredient=IngredientBlock) # Although this field is also a StreamField, it's essentially # just a list of strings. So, we're resolving it differently. instructions = graphene.List(graphene.String) def resolve_instructions(self, info): return [instruction.get('value') for instruction in self.instructions.stream_data]
Resolving images
One thing that doesn’t even show up in GraphiQL’s autosuggestions is the image field we implemented in the Recipe
model. If we manually add it to our query we’ll get an error message.
Just like our StreamFields we need to write a converter that returns a GraphQL type for all images.
# mysite/api/graphene_wagtail.py ... # Add these import statements from wagtail.images.models import Image from graphene_django import DjangoObjectType ... # Add this to the end of the file class WagtailImageNode(DjangoObjectType): class Meta: model = Image # Tags would need a separate converter, so let's just # exclude it at this point to keep the scope smaller exclude_fields = ['tags'] @convert_django_field.register(Image) def convert_image(field, registry=None): return WagtailImageNode( description=field.help_text, required=not field.null )
We now have all the different image attributes available in GraphQL.
Creating image renditions right in the GraphQL query
With this solution we have access to the URL of the original image – and to no other URL. Usually we don’t want to use the original image in the front end as it’s not optimized for that. Wagtail offers the functionality to generate image renditions that meet whatever requirement we have in the front end. We can use that and add it to our WagtailImageNode
implementation.
# mysite/api/graphene_wagtail.py ... class WagtailImageRendition(graphene.ObjectType): id = graphene.ID() url = graphene.String() width = graphene.Int() height = graphene.Int() class WagtailImageRenditionList(graphene.ObjectType): rendition_list = graphene.List(WagtailImageRendition) src_set = graphene.String() def resolve_src_set(self, info): return ", ".join( [f"{img.url} {img.width}w" for img in self.rendition_list]) class WagtailImageNode(DjangoObjectType): class Meta: model = Image exclude_fields = ['tags'] # Define all available image rendition options as arguments rendition = graphene.Field( WagtailImageRendition, max=graphene.String(), min=graphene.String(), width=graphene.Int(), height=graphene.Int(), fill=graphene.String(), format=graphene.String(), bgcolor=graphene.String(), jpegquality=graphene.Int() ) rendition_list = graphene.Field( WagtailImageRenditionList, sizes=graphene.List(graphene.Int)) def resolve_rendition(self, info, **kwargs): filters = "|".join([f"{key}-{val}" for key, val in kwargs.items()]) img = self.get_rendition(filters) return WagtailImageRendition( id=img.id, url=img.url, width=img.width, height=img.height) def resolve_rendition_list(self, info, sizes=[]): rendition_list = [ WagtailImageNode.resolve_rendition(self, info, width=width) for width in sizes ] return WagtailImageRenditionList(rendition_list=rendition_list)
Now we can create custom image renditions within our GraphQL query to suit whatever needs we have on the front end. We just need to pass the image rendition parameters available in Wagtail as arguments to our rendition
field. This is also where GraphQL aliases come in handy: If you need several different versions of the same image you can give them different names – see headerImg
in the example below.
We can even create a srcset
value for responsive images with the renditionList field.
query articles { articles { body { ... on RecipeBlock { recipe { image { file rendition( max: "750x400", format: "jpeg", bgcolor: "ffffff" ) { url width height } headerImg: rendition( fill: "1024x250-c75" ) { url } renditionList(sizes:[30, 60]) { srcSet } } } } } } }
I hope this article helps you implement a GraphQL API into your Wagtail project and gives you ideas of how to solve various issues when you run into errors. There is certainly room for improvement (for example the recipe
field within the RecipeBlock
is kind of repetitive), but that would have exceeded the scope of this article.