Extending

Contents

Extend, augment and customize the Guillotine API

Introduction

One of Guillotine’s super-powers is the ability to customize the GraphQL schema with your own input types, enums, unions, and interfaces, set or override data fetcher and type resolvers, and even modify existing interfaces, types and fields.

Get started

Add the files in this chapter to an existing Enonic app with TypeScript support, or use starter-ts to get going with a new app. See the TypeScript setup guide if your existing app does not yet compile TypeScript.

Install Guillotine’s type definitions in your app project:

npm install --save-dev @enonic-types/guillotine

The exported types are documented in TypeScript. Guillotine must also be installed in the Enonic instance where you deploy your app.

Extension file

Create src/main/resources/guillotine/guillotine.ts in your app. The build must compile it to /guillotine/guillotine.js in the application resources, which is the path Guillotine loads at runtime. The TypeScript starter handles this compilation.

This file must export the extensions function with the following structure:

import type {Extensions, GraphQL} from '@enonic-types/guillotine';

export const extensions = (graphQL: GraphQL): Extensions => {
    return {
        enums: { (1)
            // enum type definitions ...
        },
        inputTypes: { (2)
            // input type definitions ...
        },
        interfaces: { (3)
            // interfaces type definitions ...
        },
        unions: { (4)
            // unions type definitions ...
        },
        types: { (5)
            // output types definitions ...
        },
        creationCallbacks: { (6)
            // creation callback definitions ...
        },
        resolvers: { (7)
            // resolver definitions ...
        },
        typeResolvers: { (8)
            // type resolver definitions ...
        }
    }
};

You can omit any of the properties, but you must return an object with the same structure. Order of the properties is not important. The following subpages show individual extension properties; merge the ones you need into the object returned by your app’s single extensions function.

Usage example

Let’s imagine that we have to extend the GraphQL schema with a new type GoogleBooks and add a new field findBooks to the Query type to be able to find books by query string. To execute requests to Google Books API we will use lib-http-client library.

dependencies {
    include 'com.enonic.lib:lib-http-client:3.2.2'
}

In our application we have to create a new file src/main/resources/guillotine/guillotine.ts and add the following content to it:

import type {DataFetchingEnvironment, Extensions, GraphQL} from '@enonic-types/guillotine';

const httpClient = require('/lib/http-client') as HttpClient;

interface VolumeInfo {
    title?: string;
    authors?: string[];
    publisher?: string;
    publishedDate?: string;
    description?: string;
    pageCount?: number;
    language?: string;
    averageRating?: number;
}

interface BooksResponse {
    items?: Array<{id: string; volumeInfo: VolumeInfo}>;
}

interface HttpResponse {
    status: number;
    body?: string;
}

interface HttpClient {
    request(params: {
        url: string;
        method: 'GET';
        contentType: string;
        queryParams: Record<string, string>;
    }): HttpResponse;
}

const GOOGLE_BOOKS_API_KEY = app.config.googleBooksApiKey;

export const extensions = (graphQL: GraphQL): Extensions => {
    return {
        types: {
            GoogleBooks: {
                description: 'Google Books Type',
                fields: {
                    id: {
                        type: graphQL.GraphQLString,
                    },
                    title: {
                        type: graphQL.GraphQLString,
                    },
                    authors: {
                        type: graphQL.list(graphQL.GraphQLString),
                    },
                    publisher: {
                        type: graphQL.GraphQLString,
                    },
                    publishedDate: {
                        type: graphQL.GraphQLString,
                    },
                    description: {
                        type: graphQL.GraphQLString,
                    },
                    pageCount: {
                        type: graphQL.GraphQLInt,
                    },
                    language: {
                        type: graphQL.GraphQLString,
                    },
                    averageRating: {
                        type: graphQL.GraphQLFloat,
                    },
                }
            },
        },
        creationCallbacks: {
            Query: function (params) {
                params.addFields({
                    findBooks: {
                        type: graphQL.list(graphQL.reference('GoogleBooks')),
                        args: {
                            queryString: graphQL.nonNull(graphQL.GraphQLString),
                        }
                    }
                });
            },
        },
        resolvers: {
            Query: {
                findBooks: (env: DataFetchingEnvironment<{queryString: string}>) => {
                    const response = sendRequestToBooksApi(env.args.queryString);

                    return (response.items ?? []).map((item) => {
                        const volumeInfo = item.volumeInfo;

                        return {
                            id: item.id,
                            title: volumeInfo.title,
                            authors: volumeInfo.authors,
                            publisher: volumeInfo.publisher,
                            publishedDate: volumeInfo.publishedDate,
                            description: volumeInfo.description,
                            pageCount: volumeInfo.pageCount,
                            language: volumeInfo.language,
                            averageRating: volumeInfo.averageRating,
                        }
                    });
                }
            }
        },
    }
};

function sendRequestToBooksApi(queryString: string): BooksResponse {
    if (!GOOGLE_BOOKS_API_KEY) {
        throw new Error("Configure googleBooksApiKey in your app configuration");
    }
    const response: HttpResponse = httpClient.request({
        url: 'https://www.googleapis.com/books/v1/volumes',
        method: 'GET',
        contentType: 'application/json',
        queryParams: {
            q: queryString,
            key: GOOGLE_BOOKS_API_KEY,
        }
    });
    if (response.status !== 200 || !response.body) {
        throw new Error(`Google Books request failed: HTTP ${response.status}`);
    }
    return JSON.parse(response.body) as BooksResponse;
}

Set googleBooksApiKey in your app configuration before running the example. HttpClient describes the subset of the HTTP client API used here. The response interfaces describe the fields used from the Google Books API; they do not validate the response at runtime. This example checks the HTTP status but omits caching and detailed response validation.

You can separate definitions of types, creationCallbacks, resolvers and the rest of options into different files and import them into the guillotine.ts file, to make your code more readable and maintainable.

Arguments

When Guillotine invokes the extensions function, it will pass a utility object as an argument, giving your extension access to standard scalars, types, type modifiers and functions:

Scalars and Types

GraphQLString, GraphQLInt, GraphQLID, GraphQLBoolean, GraphQLFloat, Json, DateTime, Date, LocalTime LocalDateTime and reference type.

Type modifiers

The list and nonNull type modifiers allow applies additional validation of those values.

Functions

createDataFetcherResult - allows to return object with data which will be as a source for children fields and provide a localContext to share unmodifiable data available in a child field using env.localContext.

Lifecycle and execution

Guillotine loads your extension automatically and executes its functions on the server through XP’s script runtime. Your app supplies the schema definitions and resolver functions; Guillotine connects them to the GraphQL engine.

Building the schema

The schema is built lazily: the first query that needs it triggers these steps:

  1. Guillotine checks installed apps for /guillotine/guillotine.js, the compiled output of your guillotine.ts file.

  2. For each extension it can load, Guillotine calls the exported extensions(graphQL) function with the utility object described above and collects the returned definitions and functions.

  3. Guillotine combines these extensions with its generated schema. It adds custom types, invokes creationCallbacks to collect and apply schema changes, and registers field resolvers and type resolvers.

  4. The completed schema is cached and used to execute the query. Subsequent queries reuse it.

If registration of an individual field resolver fails, Guillotine logs a warning with its type and field name and continues registering the remaining resolvers. Check the XP logs when a custom resolver is not applied.

Keep schema setup in extensions and creationCallbacks, and put request-specific work, such as fetching content or calling an external API, inside resolvers. In the Google Books example, schema building registers findBooks; the HTTP request happens when a query requests that field.

Executing a query

When a query requests a field with a custom resolver, the GraphQL engine calls that resolver with env.source, env.args and env.localContext. The returned value becomes the field’s result and supplies the source for any selected child fields. See Resolvers for passing additional context to child resolvers.

For a value returned through an interface or union, the engine calls its registered typeResolver to determine the concrete GraphQL object type. See Type resolvers.

Custom field resolvers beneath guillotine execute with the project repository and branch selected for that query, so XP library calls such as content.get() use that content context. Custom fields directly on Query use the current request context. See Best practices for choosing where to add fields.

Rebuilding the schema

Application start, stop and uninstall events invalidate the cached schema. The next query rebuilds it, discovering extensions and invoking their setup functions again. Restarting Guillotine also clears its schema cache, so setup functions should support repeated invocation.


Contents

Contents