Hello GraphQL

Contents

Define a schema and expose it as a universal API.

GraphQL Library

The GraphQL Library defines the schema and executes operations against it. Its documentation describes every function and constant used in this tutorial.

Add it to build.gradle:

build.gradle
include libs.graphql

The starter declares dependencies through two version catalogs, so no version appears in build.gradle itself. Libraries from the Enonic Market, like this one, are listed in gradle/libs.versions.toml and referenced as libs.<name>:

gradle/libs.versions.toml
[versions]
graphql = "3.0.0"

[libraries]
graphql = { module = "com.enonic.lib:lib-graphql", version.ref = "graphql" }

Libraries that ship with XP are referenced as xplibs.<name> and need no entry at all — they follow the xpVersion in gradle.properties. We will use one of those in a later chapter.

Define GraphQL API

An Enonic XP application exposes its own HTTP endpoints as universal APIs. The library builds and executes the schema; the universal API is what makes it reachable over HTTP.

Create a graphql directory inside /src/main/resources/apis, holding a descriptor named graphql.yaml and a controller named graphql.ts.

/src/main/resources/apis/graphql/graphql.yaml
kind: "API"
title: "GraphQL API"
description: "Notes GraphQL API"
mount:
    - "web"
allow:
  - "role:system.everyone"

The descriptor declares which roles may reach the API, whether it is mounted under /api, and optionally a title and description.

APIs are not mounted anywhere by default, so mount and allow are what make this one reachable at all. Be aware that allow controls who may call the API and nothing more — it does not limit what a resolver returns. This tutorial allows everyone, which is fine for a local sandbox. A real API would name a narrower principal here and check permissions inside the resolvers that read or write data, using the context value passed to execute() to see who is calling.

Define schema

The code is TypeScript, so import what the schema needs from the library by name.

/src/main/resources/apis/graphql/schema.ts
import {GraphQLString, newSchemaGenerator} from '/lib/graphql';

XP resolves /lib/graphql at runtime from the JAR we added to build.gradle, so this import is not a relative path into node_modules — it is the library as the platform sees it.

A schema generator creates the types, input types, unions, enums, interfaces and the schema itself:

/src/main/resources/apis/graphql/schema.ts
const schemaGenerator = newSchemaGenerator();

Start with a single field, serverTime, which is enough to query the API and see a response.

Every schema needs a root Query type. It is the entry point for reads, and holds the fields clients can ask for.

/src/main/resources/apis/graphql/schema.ts
const rootQueryType = schemaGenerator.createObjectType({ (1)
    name: 'Query',
    fields: {
        serverTime: { (2)
            type: GraphQLString,  (3)
            resolve: () => new Date().toISOString() (4)
        },
    },
});
1 The root Query type, holding every field a client can query.
2 The field name clients ask for.
3 Its return type — a string.
4 The resolver, run when the field is queried. Here it returns the current server time.

The schema itself ties the root types together — query now, mutation and subscription in later chapters.

/src/main/resources/apis/graphql/schema.ts
const graphQLSchema = schemaGenerator.createSchema({ (1)
    query: rootQueryType,
});

export default graphQLSchema; (2)
1 The schema, built from its root types.
2 Exported so the controller can execute against it.

The controller receives requests and passes them to the engine:

/src/main/resources/apis/graphql/graphql.ts
import type {Request, Response} from '@enonic-types/core';
import {execute} from '/lib/graphql';
import graphQLSchema from './schema';

export function POST(req: Request): Response {
    const body = JSON.parse(req.body as string) as {query: string; variables?: unknown};
    const result = execute(graphQLSchema, body.query, body.variables);
    return {
        contentType: 'application/json',
        body: result,
    };
}

The export name is the HTTP method: XP looks for an export matching the request’s method, so POST handles our GraphQL requests. Nothing else is exported, so any other method — a browser visiting the endpoint, for instance — gets 405 Method Not Allowed from XP without us writing a line for it.

That is deliberate. The GraphQL over HTTP convention allows a query to be sent with GET, but a mutation must never be, since GET has to stay safe to repeat: a link preview or a prefetch could otherwise change data. Accepting only POST removes the question.

Request and Response come from @enonic-types/core, the package that types the XP runtime. It is a development dependency only — nothing from it exists at runtime.

The complete example of the schema.ts is shown below.

/src/main/resources/apis/graphql/schema.ts
import {GraphQLString, newSchemaGenerator} from '/lib/graphql';

const schemaGenerator = newSchemaGenerator();

const rootQueryType = schemaGenerator.createObjectType({
    name: 'Query',
    fields: {
        serverTime: {
            type: GraphQLString,
            resolve: () => new Date().toISOString()
        },
    },
});

const graphQLSchema = schemaGenerator.createSchema({
    query: rootQueryType,
});

export default graphQLSchema;

The application is now ready to be built, redeployed, and tested with the GraphQL API.

Test GraphQL API

In the API descriptor graphql.yaml, we defined that the API is accessible to all users (allow: ["role:system.everyone"]) and is mounted at /api (mount: ["web"]). As a result, the GraphQL API will be available at the following endpoint:

/api/<appName>:<apiKey>

In our case, since the appName is com.example.gqltutorial and the apiKey is graphql, the endpoint becomes:

/api/com.example.gqltutorial:graphql

The examples use cURL, which you likely already have installed.

curl -X POST "http://localhost:8080/api/com.example.gqltutorial:graphql" -H "Content-Type: application/json" -d "{ \"query\": \"{ serverTime }\" }"

The response:

{
   "data":{
     "serverTime":"2025-07-08T12:44:22.049Z"
   }
}

Great! We now have a working example of a simple GraphQL API.

The endpoint is an ordinary HTTP POST endpoint that takes a GraphQL request body, so any GraphQL client works against it — Postman, Insomnia, Altair, a standalone GraphiQL, or an IDE plugin. This tutorial uses cURL so every step can be verified from a terminal.

Clients that offer schema autocompletion get it through GraphQL introspection, which the library supports out of the box. Nothing needs enabling for a client to discover the types and fields you define.

Handling errors

Not every request succeeds, and GraphQL reports failures in the response body rather than through the HTTP status code. Ask for a field the schema does not define:

curl -X POST "http://localhost:8080/api/com.example.gqltutorial:graphql" -H "Content-Type: application/json" -d "{ \"query\": \"{ notAField }\" }"

The query never runs, and the response carries an errors array instead of data:

{
   "errors":[
      {
         "errorType":"ValidationError",
         "message":"Validation error (FieldUndefined@[notAField]) : Field 'notAField' in type 'Query' is undefined",
         "locations":[{"line":1,"column":3}],
         "validationErrorType":"FieldUndefined"
      }
   ]
}
The wording of message comes from the GraphQL engine and may change between versions. Match on errorType rather than on the message text.

A failing resolver behaves differently. The field it serves resolves to null, the rest of the query still returns its values, and the response then contains both data and errors. Clients must therefore check errors even when data is present — a 200 response does not mean everything succeeded.

The completed application also guards the request itself, returning 400 with an errors array when the body has no query property, so malformed requests fail in the same shape clients already handle.

Coming up

In the next chapter you’ll extend the schema with mutations so clients can change data, not just read it.


Contents

Contents