I alluded to a solution coming down the line from Gatsby called Schema Customisation. Well this feature landed in version v2.2.0 about a week after that article was written. There has also been an issue open on the gatsby-source-kentico-cloud plugin’s GitHub repository describing the need to resolve the issue raised in my article - namely that if no items exist for a particular content type then that type will not exist in the Gatsby schema, leading to build errors. As the Kentico team have been somewhat busy as of late, with the rebrand to Kontent and their recent Kentico Connection in Brno, I decided to put together a proof of concept for generating the Gatsby schema directly using the new API.
If you’re the sort of person who just wants to see the code then you can find the latest version in my sandbox project here:
I felt it would be useful to talk through some of this code in more detail to explain what is happening, and why.
Firstly, we’re using data from Kontent to generate our schema, therefore you will need to register for a new account on their website. Once you have registered, take some time to create some content models and a variety of example content items based on those models. After you have some content to work with you can continue on to the next step.
We now need to retrieve the data from Kontent’s API, we can do this using their Delivery SDK. This can be installed by running:
We can now use this data to create our Gatsby schema!
Firstly, we create a whole bunch of base GraphQL types to ensure we have shared types available for fields and also common interfaces to make querying some of this data much easier! All of the base types can be found in the createBaseTypes function.
Once we’ve defined these shared GraphQL types we can start declaring the schema for our actual content models.
/** * Handles creating GraphQL types for all content types. */asyncfunctionhandleTypeCreation() {// Create base types for Kontent schema.createTypes(createBaseTypes(schema));// Create types for all Kontent types.constresponse=await client.types().toPromise();createTypes(response.types.reduce((acc, type) => acc.concat(createTypeDef(schema, type)), []));}
We will retrieve the types from the Kontent API then reduce those into an array of types - note we use a reduce function rather than a map function as we’re returning multiple types from the “createTypeDef” function and need to flatten the result. The same result could have been achieved by using a map function and simple flattening the resulting nested array. The final array of type definitions is passed to Gatsby’s “createTypes” function.
The actual type definition is handled by the createTypeDef function.
Here we use the new Type Builders API to construct a new GraphQL type. We try to mimic the general structure of the Kontent API by including both a top-level “system” and “elements” field. The “system” field is configured to use the “KontentItemSystem” type that we created earlier in the createBaseTypes function. This will be the same for all content models.
The “elements” field is configured to use a model-specific GraphQL type as the available fields will be different for each content model.
// Create field definitions for Kontent type elements.constelementFields= type.elements.reduce((acc, element) => {constfieldName=getGraphFieldName(element.codename);return Object.assign(acc, { [fieldName]: { type: getElementValueType(element.type), }, });}, {});constelementsTypeDef= schema.buildObjectType({ name: `${getGraphTypeName(type.system.codename)}Elements`, fields: elementFields, infer: false,});
Firstly, we reduce the elements array into an object that includes the correct type for each field. This is done based on convention, the field name is generated by converting the Kontent codename value into camel case.
The element’s GraphQL type is defined by converting it’s Kontent field type into pascal case and sandwiching it between the keywords “Kontent” and “Element”, e.g. “KontentTextElement” for a “text” field.
/** * Return appropriate GraphQL type for Kontent element type. * @param{String}elementType */functiongetElementValueType(elementType) {return`Kontent${changeCase.pascalCase(elementType)}Element`;}
We then create a new type for the “elements” field itself with inference disabled using the “infer” property on the type builder definition.
Finally, we create the actual type for our content model by defining the name using pascal case conversion from the codename value and specifying the fields mentioned earlier. Finally, we ensure our type implements both the “Node” and “KontentItem” interfaces. The former is the interface all Gatsby node types must implement to ensure they can be queried. The latter is a custom interface we defined to provide a common contract between all of our content models, this can be handy when querying fields later or when creating connections between types via the “@link” directive.
At this stage you could run your Gatsby site and check the schema using GraphiQL, you would notice that all of our types exist but if you execute a query you will see no data returned. Now we can look at fixing that!
First of all we need to retrieve our content items from the Kontent API. This includes two collections of items on the “items” and “linkedItems” properties. We don’t really care about the difference between these two so we can simply combine these arrays and union them based on the “system.codename” property to ensure we do not have any duplicates.
/** * Handles creating Gatsby nodes for all content items. */asyncfunctionhandleNodeCreation() {// Create nodes for all Kontent items.constresponse=await client.items().toPromise();constallItems=unionBy(response.items, response.linkedItems, 'system.codename'); allItems.forEach((item) => {constitemNode=createItemNode(createContentDigest, createNodeId, item);createNode(itemNode); });}
Finally, we will create a Gatsby node for each content item and pass that to Gatsby’s “createNode” function. The node creation is handled by the “createItemNode” function.
/** * Create Gatsby node for Kontent item. * @param{Function}createContentDigest * @param{Function}createNodeId * @param{KontentDelivery.ContentItem}item */functioncreateItemNode(createContentDigest, createNodeId, item) {// Get all keys where the property has a "type" property.constelementPropertyKeys= Object.keys(item).filter((key) => item[key] && item[key].type);// Create object with only element keys.constelements= elementPropertyKeys.reduce((acc, key) => {constfieldName=getGraphFieldName(key);let fieldValue;constelementValue= item[key];if (elementValue.type ==='modular_content') {// Transform modular content fields to support linking. fieldValue = { name: elementValue.name, type: elementValue.type, value: elementValue.itemCodenames, }; } elseif (elementValue.type ==='rich_text') {// Transform rich text fields to support linking. fieldValue = { images: elementValue.images, linkedItems: elementValue.linkedItemCodenames, links: elementValue.links, name: elementValue.name, type: elementValue.type, value: elementValue.value, }; } else { fieldValue = elementValue; }// Remove the raw data field.delete fieldValue.rawData;return Object.assign(acc, { [fieldName]: fieldValue }); }, {});let nodeData = { system: item.system, elements: elements, };// Gatsby is not a fan of dealing with types vs plain objects// so we'll re-serialize the data to make plain ol' objects! nodeData =JSON.parse(JSON.stringify(nodeData));constnodeMeta= { id: createNodeId(getNodeIdValue(nodeData)), parent: null, children: [], internal: { type: getGraphTypeName(nodeData.system.type), mediaType: 'text/html', contentDigest: createContentDigest(nodeData), }, };constnode= Object.assign({}, nodeData, nodeMeta);return node;}
Finally, we can run the Gatsby site again and you should now be able to see data returned when executing your queries. The great thing about this approach is that if you delete all items of a particular model, or if none of the items have data for a particular field, then you’ll be safe in the knowledge that your queries will still work as intended!