Server Driven UI, Part 2: The GraphQL API

In the last article, we dived into the Concept of server driven UI – if you haven’t taken a look at that blog post yet, it will serve as a useful reference for the concepts mentioned throughout this post – so I would recommend checking it out. In this article I want to dive into building the GraphQL API for Compose Academy that is being used to serve the UI to its clients.

With a server-driven UI we go from being given the data to be displayed on a screen, to being given the entire presentation of a screen. So currently without server-driven UI, we get some form of data that is to be displayed – which the client is responsible for how this should be presented.

With server-driven UI, our response is made of the UI components for a screen, along with the data to be shown in each of them, rather than just the data by itself. This means that our client now only needs to render the given components, rather than manually massaging the data and delegating the data to components by itself.

This takes the responsibility of the presentation of our screen away from the client and gives it to our server. Where multiple clients are in place, this not only helps us to create a consistent experience across products, but it reduces the need to maintain the UI of the different screens for each client. This all sounds very magical, so how does this work? We’ll dive more into each of the concepts throughout this article, but at a high level:

  • When a client needs to request the presentation for a screen, they’re going to perform a query to do so. In the case of Compose Academy this will be either to query the global categories, the subjects for a category, the topics for a subject, or an individual article.
  • The resolver for this query will query the database for the required data (just as we would with non-server driven UI)
  • The resolver will then create the presentation for the screen in the form of a collection of objects. This list essentially defines how the screen should look and what it is made up of.
  • The resolver will return this presentational object, which will be mapped by graphql to the corresponding Component types using a defined Type resolver
  • Once all of these things have been mapped by graphql, a Presentation reference will be returned from the query

With all of this, we end up with an API that has been handed a lot of the responsibility that the client previously owned. There’s a lot going on here though, with a bunch of different moving parts – so lets take some time to go over the internals of what’s going on here. Talking about how an entire API built in a single article is a lot to cover, so I’ve tried to brush over things at a level, presenting this more as a concept.


Declaring supported UI components

For server-driven UI to work smoothly, we want to define the UI components that are currently supported – this is essentially going to represent the design system of our product. Within our schema the supported components are going to begin as definitions for the ComponentType enum. This will be used for simply defining each of the components using a descriptive name.

Enums? but why? So, because we’re using a Component interface (which makes each of our UI components a generic type), we need our graphql resolver to be able to map to a Component implementation. We’ll come to this more in a bit, but the resolver will essentially take this enum and use it to map an object to the corresponding Component implementation. At a Schema level, this enum and its corresponding values take the following form:

const componentTypes = gql`

    "Supported UI components"
    enum ComponentType {

        "A detail card displays child components within a card view"
        DETAIL_CARD,

        "A toolbar is the header for a screen, used to display a title and any child components"
        TOOLBAR

        "Breadcrumbs are navigational links that represent where the user currently is"
        BREADCRUMBS

        "A vertical content container that supports scrolling"
        VERTICAL_SCROLLER

        "A search bar that allows textual input to search for content"
        SEARCH_BAR

        "Text that is to be used as a title for screens or content sections"
        TITLE_TEXT

        "Body text that is to be used for standard content"
        TEXT

        "An image component used to display images on screen"
        IMAGE

        "A component used to display formatted snippets of code"
        CODE_SNIPPET

        "A component used to display markdown content"
        MARKDOWN

        "A component used to display a hyperlink"
        LINK

        "A container for the content of an article"
        ARTICLE_CONTENT

        "A footer component used to place content at the bottom of a screen"
        FOOTER

        "A component used to link to the next/previous article"
        ARTICLE_LINK
    }
`

With this in place, we now need to define the actual UI components and the structure that each of them will take. Each of these is going to be defined as a Type within our schema, meaning that each UI component can be returned as a strongly-typed component – in our case, a Component interface implementation.

Below shows some of the UI components from the types that have been defined above. I’ve only included a subset of these as we don’t need to see them all here to demonstrate how this works.

const uiComponents = gql`

    interface Component {
        "The ComponentType that represents the type of this UI component"
        type: ComponentType!
    }

    type Image implements Component {
        "The URL of the image to be loaded"
        content: String!
        type: ComponentType!
    }

    type CodeSnippet implements Component {
        "The URL for the dark theme code snippet"
        urlDark: String!
        "The URL for the light theme code snippet"
        urlLight: String!
        type: ComponentType!
    }

    type Markdown implements Component {
        "The markdown content to be rendered"
        content: String!
        type: ComponentType!
    }

    type Text implements Component {
        "The textual content to be displayed"
        content: String!
        type: ComponentType!
    }

    type Link implements Component {
        "The text to be displayed for the link"
        link: String!
        "The slug for the linked location"
        slug: String!
        type: ComponentType!
    }

    type DetailCard implements Component {
        "The title to be displayed on the card"
        title: String!
        "The description for the card content"
        description: String
        "The slug for where the card links to"
        slug: String!
        type: ComponentType!
    }

    type VerticalScroller implements Component {
        "The list of components to be displayed in the scroller"
        content: [Component!]!
        type: ComponentType!
    }

    type Toolbar implements Component {
        "The list of components to be displayed within the toolbar"
        children: [Component!]
        type: ComponentType!
    }
`;

With this in place, we can slowly start to see how this is going to come together. Now we have defined types for each of our UI components, which can be returned to our clients in a strongly-typed format. Each component implementation represents a UI component which is to be displayed by the client, implementing the Component interface which in turn enforces the declaration of the ComponentType which is represents.

Once the Component implementation has declared its type, it is free to define the properties which it is made up of. In the types above we can see that each component declares various properties which is specific to its implementation. When the client receives a presentation and the typed components, these properties will be used to populate the content of those components.


Returning Presentation to the Clients

Now that we have our UI components, we need to serve them to the client. With each of the queries that can be performed we’re going to return a Presentation type – for now this contains a collection of Component types that are to be rendered by the client.

const queries = gql`
    type Presentation {
        "The children that make up the presenation of a screen"
        children: [Component!]!
    }
`;

This Presentation type helps to keep our query responses abstract (and reusable), as well as defining a standard for what clients require when it comes to presentation. Any query that is executed will return a presentation type which the clients can then parse accordingly. For more complex layouts you could define a type per presentation, but I haven’t seen a need for that within this project yet.

When a client performs a query it will receive back this Presentation reference, which can then be used to access these Component references.

Compose Academy has several query operations that can be performed, each of them returning a Presentation for their response. These different queries are defined within the schema:

const queries = gql`
    type Query {

        "Perform a search operation from the article collection"
        search(query: String!): Presentation!

        "Retrieve the presentation for the categories screen"
        categories: Presentation!

        "Retrieve the presentation for the subjects screen"
        subjects(path: String!): Presentation!

        "Retrieve the presentation for the topics screen"
        topics(path: String!): Presentation!

        "Retrieve the presentation for the article screen"
        article(path: String!): Presentation!
    }
`;

We don’t need to look at how all of these queries are resolved, as they are handled in pretty much the same way. For now we’ll device into the categories query – this is used for the home screen of the Compose Academy website.


Resolving Queries

When a query is performed, this is routed through to a resolver which needs to handle that query – in the case of Compose Academy, this means fetching the data from the database and massaging it into a Presentation format to be returned.

For each screen there is a different query, with each having a data model which represents the type of data that is shown on the screen. So in the case of the Categories query, we will be displaying a collection of Category references within the presentation. Each category is stored in the database as an individual entity, in which we reference using a Category in the form of a mongoose schema:

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const category = new Schema({
    title: {
        type: String,
        required: true
    },
    description: {
        type: String,
        required: false
    },
    slug: {
        type: String,
        required: true
    },
},
{
    collection: 'categories',
});

module.exports = mongoose.model('Category', category);

This schema is then used to retrieve the categories within the corresponding resolver when the query is performed. So within our categories resolver we will start by performing a find operation on our Category schema, which returns all categories from the collection mongo collection.

const Category = require('../models/category');

module.exports = {
    Query: {
        categories: async () => {
            const items = await Category.find({}, null, {sort: {title: 1}});
        },
    }
};

Now that we have these categories, we need to do some massaging so that they can be returned in the required format for our clients. What we return here needs to match the types which we defined earlier in this post – so we will need to return a Presentation which consists of a collection of Component references. We’ll start here be returning an object that matches the representation of our Presentation type – this consists of an object containing an array of objects for its children argument. Because our schema defines that the categories query returns a Presentation type, graphql knows that our resolver is returning this type.

categories: async () => {
    const items = await Category.find({}, null, {sort: {title: 1}});
    return {
        children: [  
            ...
        ],
    };
};

We’re going to begin by returning the toolbar which is to be displayed by our client – this again needs to match the requirements of the types that we defined earlier in our schema. For the Toolbar component, we need to match the defined specification:

type Toolbar implements Component {
    "The list of components to be displayed within the toolbar"
    children: [Component!]
    type: ComponentType!
}

  • children: the children components that are going to be displayed within the toolbar
  • type: the component type from our design system, so that graphql can map our object from the resolver to a type from our schema

We’ll start here by creating an object for our array of children, providing the require type, along with a nested child that will be displayed within our toolbar. For this nested child we’ll then do a similar thing there as we have done for the toolbar itself, except here we’ll provide the SEARCH_BAR from our defined design system – and as per our schema, this only requires a label which will be used for the search bar hint.

{
    type: 'TOOLBAR',
    children: [
        {
            label: "Search Compose Academy",
            type: SEARCH_BAR
        },
    ]
}

From this, we can see how our type definitions for our UI component matches that of the object that we have constructed in our resolver.

At this point we have a a toolbar with an individual child. Because we are returning an array of children, we can return other types of children to be displayed within the toolbar component. We’re going to use another component from our design system, this time in the form of the BREADCRUMBS type.

{
    type: 'TOOLBAR',
    children: [
        {
            label: "Search Compose Academy",
            type: SEARCH_BAR
        },
        {                                                  
            content: [
                {
                    link: "Home",
                    slug: "/",
                    type: LINK
                },
            ],
            type: BREADCRUMBS
         },
    ]
}

Again, this needs to match the Breadcrumbs type that is defined in our schema, along with any children components that it uses within its content property. Going back to out schema type definitions, we can see how each of these types mp to the object that we’ve just defined.

If we put this all together, along with an additional UI component being returned for the main content of the screen, our resolver now returns an object that represents the presentation of our category selection screen.

const Category = require('../models/category');

module.exports = {
    Query: {
        categories: async () => {
            const items = await Category.find({}, null, {sort: {title: 1}});
            return {
                children: [
                    {
                        type: TOOLBAR,
                        children: [
                            {
                                label: "Search Compose Academy",
                                type: SEARCH_BAR
                            },
                            {
                                content: [
                                    {
                                        link: "Home",
                                        slug: "/",
                                        type: LINK
                                    },
                                ],
                                type: BREADCRUMBS
                            },
                        ]
                    },
                    {
                        type: VERTICAL_SCROLLER,
                        content: items.map(function (key, index) {
                            return {
                                title: items[index].title,
                                description: items[index].description,
                                image: items[index].title,
                                slug: items[index].slug,
                                type: DETAIL_CARD
                            };
                        })
                    },
                ]
            };
        },
    }
};

I won’t show examples here, but having unit tests for these resolver allow you to assert that the correct UI components are being returned for a specific presentation. This allows you to make changes to the presentation for our clients, whilst being assured that you won’t break the existing presentation being returned by your API.


Resolving UI components

Now that our resolver is returning an object that represents the required format of the types defined in our schema, we need to actually map to those defined types to their corresponding Component references. Because our Component interface is generic, we need to be able to take the defined type from our resolver (e.g type: ‘SEARCH_BAR’) and map that to a type that is defined in our graphql schema. Once graphql knows what object it needs to map to, the rest is done automatically for us. For this, we’re going to need to utilise a type resolver that will map the objects from our resolver to the types in our schema.

A type resolver takes an object given to it by the query resolver, and returns the type that the object represents. So in our case, the type resolver will use the enum type that we’ve utilised within our query resolver and when there is a match, the type of Component from our schema will be returned. So in the case when the TOOLBAR enum is detected in an object from our query resolver, Toolbar will be returned and that object will then be mapped to the Toolbar type from our schema.

module.exports = {
    Component: {
        __resolveType: (component) => {
            switch (component.type) {
                case TOOLBAR:
                    return 'Toolbar';
                case BREADCRUMBS:
                    return 'Breadcrumbs';
                case DETAIL_CARD:
                    return 'DetailCard';
                case VERTICAL_SCROLLER:
                    return 'VerticalScroller';
                case SEARCH_BAR:
                    return 'SearchBar';
                ...
            }
        },
    },
};

Again, having tests for type resolvers allow you to ensure that the correct types are being resolved when components are provided to them.


Resolving the Article screen

Whilst the Category, Subject and Topic screens in Compose Academy are not complex, the Article screen has a lot more unpredictability and I think this is where the server-driven UI really shines the most. An article can display a range of components (text, images, markdown, links, code snippets etc) and these can be in any kind of order – having the presentation of these returned by a the server simplifies the handling of the UI for the clients, it also means that we can alter things with ease, or even add new elements across clients without the need to modify much native code.

As we’ve already covered the resolution of the Category screen, we have a slight grasp of how the Presentation is served to our clients. For the Article screen, I just want to share what the resolver looks like:

const Article = require('../models/article');
const Subject = require('../models/subject');
const Topic = require('../models/topic');
const Category = require('../models/category');
const TextComponent = require('../models/text');
const CodeComponent = require('../models/code');
const MarkdownComponent = require('../models/markdown');

module.exports = {
    Query: {
        articleByPath: async (parent, args, req) => {
            const parts = args.path.substring(1).split('/');
            const category = await Category.findOne({slug: parts[0]});
            const subject = await Subject.findOne({slug: parts[1]});
            const topics = await Topic.find({subject: subject._id}).sort({'title': 1});

            const topicPosition = findWithAttribute(topics, 'slug', parts[2]);
            const topic = topics[topicPosition];

            let previousTopic = null;
            if (topicPosition > 0) previousTopic = topics[topicPosition - 1]
            let nextTopic = null;
            if (topicPosition < topics.length - 1) nextTopic = topics[topicPosition + 1]

            const article = await Article.findOne({topic: topic._id});
            const children = article.content;
            children.sort((a, b) => a.position - b.position);

            return {
                children: [
                    {
                        type: TOOLBAR,
                        children: [
                            {
                                title: article.title,
                                type: TITLE_TEXT
                            },
                            {
                                content: [
                                    {
                                        link: "Home",
                                        slug: "/",
                                        type: LINK
                                    },
                                    {
                                        link: category.title,
                                        slug: `/${category.slug}`,
                                        type: LINK
                                    },
                                    {
                                        link: subject.title,
                                        slug: `/${category.slug}/${subject.slug}`,
                                        type: LINK
                                    },
                                    {
                                        link: topic.title,
                                        slug: `/${category.slug}/${subject.slug}/${topic.slug}`,
                                        type: LINK
                                    }
                                ],
                                type: BREADCRUMBS
                            },
                        ]
                    },
                    {
                        content: children.map(async function (key, index) {
                            if (children[index].kind === ComponentContentText) {
                                let textComponent = await TextComponent.findOne({"_id": children[index].ref});
                                return {
                                    content: textComponent.text,
                                    type: TEXT
                                };
                            } else if (children[index].kind === ComponentContentCode) {
                                let codeComponent = await CodeComponent.findOne({"_id": children[index].ref});
                                return {
                                    urlDark: `${process.env.SNIPPET_BASE_URL}${codeComponent.url_dark}`,
                                    urlLight: `${process.env.SNIPPET_BASE_URL}${codeComponent.url_light}`,
                                    type: CODE_SNIPPET
                                };
                            } else if (children[index].kind === ComponentContentMarkdown) {
                                let markdownComponent = await MarkdownComponent.findOne({"_id": children[index].ref});
                                return {
                                    content: markdownComponent.markdown,
                                    type: MARKDOWN
                                };
                            }
                        }),
                        slug: article.slug,
                        type: ARTICLE_CONTENT
                    },
                    {
                        content: [
                            {
                                articleTitle: previousTopic ? previousTopic.title : null,
                                articleSlug: previousTopic ? previousTopic.slug : null,
                                direction: 'PREVIOUS',
                                type: ARTICLE_LINK
                            },
                            {
                                articleTitle: nextTopic ? nextTopic.title : null,
                                articleSlug: nextTopic ? nextTopic.slug : null,
                                direction: 'NEXT',
                                type: ARTICLE_LINK
                            }
                        ],
                        type: FOOTER
                    }
                ]
            };
        },
    }
};

We can see here that overall the approach is pretty much the same as when we were handling the Category presentation. The main difference here are the number of different UI components returned, along with the unpredictability of the article content.


Throughout this post we’ve explored how the Compose Academy API has been built to serve UI presentation to its clients. Using a clearly defined design system we can shift the responsibility of presentation away from the clients and allow the server to define what this should mean. When it comes to testing, maintenance and consistency, server-driven UI allows us to reap many benefits in each of these areas. Originally when building this I was worried it would end up being quite pieced together, but with the schema, resolvers, tests and strong-typing we can see how there there is little room for error – because of the strongly-typed format of graphql, the project would not compile if we say, missed something out of the type resolver. The strengths of the technologies used come together to ensure that the API remains predictable in its functionality.

When it comes to building the API, there was definitely more complexity than if I was just to return say, a list of categories. However, I’ve taken the complexity of crafting the presentation for a screen and only needed to tackle it a single time, rather than multiple times for different clients. And whilst most of the Presentation cases for Compose Academy are not super complex, we can begin to think about the places where server-driven UI can really help to streamline our product development process.

In the next post we’ll be diving into how the Web Client has been built to consume this data from the API, dynamically building its presentation from the server in a strongly typed format. Stay tuned!

Leave a Reply

Your email address will not be published. Required fields are marked *