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.