Get started

Tutorials

14 Jun 2018

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.

oli.jpg

Oliver Sartun

Web engineer, 8fit GmbH

Wagtail GraphQL Logos

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
  }
}
GraphQL: Error screen – Can't convert StreamField

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.

GraphiQL: Showing StreamField value 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.

Wagtail Admin: Example snippet

Go to your GraphiQL interface and run the query. You won’t get any errors, but the result is disappointing.

GraphiQL: JSON StreamField output with an embedded snippet

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
        }
      }
    }
  }
}
GraphQL: Using Inline Fragments on a StreamField

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.

GraphiQL: Error message on image field

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.

GraphiQL: Resolving image nodes with working autosuggestion

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.

GraphiQL: Generating image renditions within the query
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.