Errors as Data with GraphQL using Spring

A minimalistic example to demonstrate how it can be done

In GraphQL solutions Errors as Data approach provides more expressiveness to the api. This is the promise and this article is not about this, but about what setup is needed and what tools exist to make it easy dealing with this approach.

Beside the fact that this approach requires wrapping you head around it a bit, it might bring some pain because the union type will be an interface. Transforming an interface to json is can be pain with FasterXML.

We have two options: using a code generator or maintaining the java side by hand.

The solutions below show only the relevant part of the code. You may find the whole project at the solutions.

Let's consider the following super minimal GraphQL Api. Nothing fancy. A union type which can be either a result or an error.

type Query{
    getArticlesSuccess: ArticleResult!
    getArticlesError: ArticleResult!
}

union ArticleResult = ArticleOutput | SomeError

type ArticleOutput {
    articles: [Article]
}

type Article {
    id: ID!
    name: String
}

type SomeError {
    message: String!
}

Solution 1: Code generator

Tool: code generator

Source code: Github

The tool here is the graphql-java-codegen created by Kobylynskyi. The following code snippet is for demonstration purposes only, however it works.

<plugin>
    <groupId>io.github.kobylynskyi</groupId>
    <artifactId>graphql-codegen-maven-plugin</artifactId>
    <version>5.10.0</version>
    <executions>
        <execution>
            <goals>
                <goal>generate</goal>
            </goals>
            <configuration>
                <graphqlSchemas>
                    <rootDir>${project.basedir}/src/main/resources/graphql/</rootDir>
                    <includePattern>.*\.graphqls?</includePattern>
                    <recursive>true</recursive>
                </graphqlSchemas>
                <outputDir>${project.basedir}/target/generated-sources/graphql</outputDir>
                <packageName>com.example.demo.model</packageName>
                <generateBuilder>true</generateBuilder>
                <generateJacksonTypeIdResolver>true</generateJacksonTypeIdResolver>
                <addGeneratedAnnotation>true</addGeneratedAnnotation>
            </configuration>
        </execution>
    </executions>
</plugin>

When the project is built it will generate the entities from the GraphQL schema. The important part is the following interface. The annotations below are added by the code generator due to the following section in the Maven config: <generateJacksonTypeIdResolver>true</generateJacksonTypeIdResolver>

@javax.annotation.processing.Generated(
    value = "com.kobylynskyi.graphql.codegen.GraphQLCodegen",
    date = "2024-06-10T22:52:45+0200"
)
@com.fasterxml.jackson.annotation.JsonTypeInfo(
  use = com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME, 
  property = "__typename")
@com.fasterxml.jackson.databind.annotation.JsonTypeIdResolver(
com.example.demo.model.GraphqlJacksonTypeIdResolver.class)
public interface ArticleResult {

}

The other file we need to take a look at is that GraphqlJacksonTypeIdResolver type. It is provided by the generator and looks like below. You don't have to know what is in it and why. It will be interesting when you want to do something similar for yourself for example if you want to maintain these files by hand (Option 2 below).

package com.example.demo.model;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.DatabindContext;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.jsontype.impl.TypeIdResolverBase;

@javax.annotation.processing.Generated(
    value = "com.kobylynskyi.graphql.codegen.GraphQLCodegen",
    date = "2024-06-11T20:13:41+0200"
)
public class GraphqlJacksonTypeIdResolver extends TypeIdResolverBase {

    private JavaType superType;

    @Override
    public void init(JavaType baseType) {
        superType = baseType;
    }

    @Override
    public JavaType typeFromId(DatabindContext context, String typename) {
        try {
            Class<?> clazz = Class.forName(
                "com.example.demo.model." +
                typename
            );
            return context.constructSpecializedType(superType, clazz);
        } catch (ClassNotFoundException e) {
            System.err.println(e.getMessage());
            return null;
        }
    }

    @Override
    public JsonTypeInfo.Id getMechanism() {
        return JsonTypeInfo.Id.NAME;
    }

    @Override
    public String idFromValue(Object obj) {
        return idFromValueAndType(obj, obj.getClass());
    }

    @Override
    public String idFromValueAndType(Object obj, Class<?> subType) {
        return subType.getSimpleName();
    }
}

At this point the application will work and the following query will result the result showed after the query.

query {
  getArticlesSuccess {
    __typename
    ... on ArticleOutput {
      articles {
        id
        name
      }
    }
    ... on SomeError {
      message
    }
  }
  getArticlesError {
    __typename
    ... on ArticleOutput {
      articles {
        id
        name
      }
    }
    ... on SomeError {
      message
    }
  }
}

As you can see below both queries returned the ArticleResult implemented interface and Jackson just converted it to the proper types without any issue.

{
  "data": {
    "getArticlesSuccess": {
      "__typename": "ArticleOutput",
      "articles": [
        {
          "id": "1",
          "name": "name1"
        },
        {
          "id": "2",
          "name": "name2"
        }
      ]
    },
    "getArticlesError": {
      "__typename": "SomeError",
      "message": "error happened"
    }
  }
}

Solution 2: maintaining the entities by hand

This one requires going deeper in Jackson and I'll add later how to do it.