A Record on Using Spring GraphQL

Spring GraphQL is a new Spring Application integrating GraphQL, released by Spring this year. Thanks to the out-of-the-box features of Spring Boot, it allows for the quick construction of a usable GraphQL service. However, despite its usability, the documentation leaves much to be desired, especially for some advanced features, which require self-exploration through the API documentation.

The following records the related usage.

Setup

Initialization can be done by building a Spring Boot project.

Using Gradle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // Spring GraphQL Boot starter
    implementation 'org.springframework.experimental:graphql-spring-boot-starter:1.0.0-SNAPSHOT'

    // ...
}

repositories {
    mavenCentral()
    maven { url 'https://repo.spring.io/milestone' }  // Spring milestones
    maven { url 'https://repo.spring.io/snapshot' }   // Spring snapshots
}

Or using Maven

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<dependencies>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    // Spring GraphQL Boot starter
    <dependency>
        <groupId>org.springframework.experimental</groupId>
        <artifactId>graphql-spring-boot-starter</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </dependency>

    <!-- ... -->

</dependencies>

<!-- For Spring project milestones or snapshot releases -->
<repositories>
    <repository>
        <id>spring-milestones</id>
        <name>Spring Milestones</name>
        <url>https://repo.spring.io/milestone</url>
    </repository>
    <repository>
        <id>spring-snapshots</id>
        <name>Spring Snapshots</name>
        <url>https://repo.spring.io/snapshot</url>
        <snapshots>
            <enabled>true</enabled>
        </snapshots>
    </repository>
</repositories>

After importing the dependencies, place the Graphql Schema file in the resources/graphql directory and make the corresponding configuration to run. After running, you can see the web version of the playground at the set path.

1
2
3
4
5
6
7
8
9
spring:
  graphql:
    graphiql: # Provides a web-based playground for assistance
      enabled: true
      path: /
    schema: # Provides schema
      printer:
        enabled: true
    path: /query # Graphql endpoint

Query and Mutation

The queries and mutations provided in the Schema are mapped to methods in the Controller through annotations for processing and returning the corresponding data. The convention method is used by default here, as long as the name of the method and its parameters are consistent with the Schema declaration, it can be automatically mapped, otherwise it can be set through the parameters of the annotation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Controller
public class TodoController {

    @QueryMapping
    public List<TodoItem> todoItems(GraphQLContext cxt) {
        var u = getUserInContext(cxt);
        return todoService.getTodoItemsByUser(u);
    }

    @MutationMapping
    public TodoItem createTodoItem(@Argument TodoItemInput todo, GraphQLContext cxt) {
        var user = getUserInContext(cxt);
        return todoService.createTodoItem(todo, user);
    }
}

The method's parameters support:

  • DataFetchingEnvironment: Contains rich related information in this query, including query variables, context information, etc.
  • The @Argument annotation can bind the corresponding variables to the parameters.
  • GraphQLContext: Used to store the corresponding data content in the access context.

Authorization

How to perform authorization, the document simply gives a brief introduction saying that Spring Security can be used for authorization. However, my needs are not so complicated, I just need to simply intercept requests, check whether there is token access, if there is token access, then convert it into the corresponding user and give it to the subsequent Controller for processing.

Here I use WebInterceptor to handle request interception, the main idea is:

  1. Intercept each request and try to take the Authorization field in the request header.
  2. If there is no such field, skip it.
  3. If there is this field, try to get the corresponding User based on the token, if not found, throw an invalid token error.
  4. After finding it, add the User object to the graphQLContext for the Controller to use.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Component
public class MyInterceptor implements WebInterceptor {

    private final UserService userService;

    @Autowired
    public MyInterceptor(UserService userService) {
        this.userService = userService;
    }

    @Override
    public Mono<WebOutput> intercept(WebInput webInput, WebGraphQlHandler next) {
        var authHeader = webInput.getHeaders().get("Authorization");
        var q = webInput.toExecutionInput();
        if (Objects.isNull(authHeader)||authHeader.size()==0) {
            return next.handle(webInput);
        }

        var user = userService.getUserByToken(authHeader.get(0));
        if (user.isEmpty()) {
            return Mono.just(new WebOutput(webInput,
                    new ExecutionResultImpl(GraphqlExceptionBuilder.genError(GraphqlExceptionBuilder.ErrorType.INVALID_TOKEN))));
        }

        webInput.configureExecutionInput(((executionInput, builder) -> {
            Map<String, Object> context = new HashMap<>() {{
                put("user", user.get());
            }};

            return builder.graphQLContext(context).build();
        }));

        return next.handle(webInput);

    }

}

In subsequent controller methods, if user information or user permissions are needed, just take out the corresponding object from the graphQLContext, if not available, throw the corresponding error.

1
2
3
4
private User getUserInContext(GraphQLContext cxt) {
        return (User) cxt.getOrEmpty("user")
                .orElseThrow(()-> GraphqlExceptionBuilder.genError(GraphqlExceptionBuilder.ErrorType.FORBIDDEN));
    }

Error Handler

All exceptions thrown during the application run will eventually be returned to the client in the form of GraphQL. At first, I thought that the thrown GraphQLError would be directly returned to the user, so I directly threw an error in the code, trying to return it to the user through the Controller. However, I found that in this way, the extensions I set in the GraphlQLErrorException would always have no effect, but would be classified as INTERNAL_ERROR.

Later I found that all exceptions would run to something called an ErrorResolver for processing, which would repackage the Error again. So we must write our own ExceptionHandler to override it.

1
2
3
4
5
6
7
@Component
public class DataFetcherExceptionHandler extends DataFetcherExceptionResolverAdapter {
    @Override
    public GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
            return (GraphQLError) ex;
    }
}

In addition, the generation of GraphQLError is assisted by a custom Builder tool class, which generates Errors containing message and error through enumeration types.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class GraphqlExceptionBuilder {

    public enum ErrorType {
        DUPLICATED_USER,
        NO_USER,
        WRONG_PASSWORD,
        INVALID_TOKEN,
        FORBIDDEN,
        NO_ITEM,
    }

    public static Map<String, Object> updateErrorCode(ErrorType errorType) {
        return new HashMap<>() {{
            put("code", errorType.name());
        }};
    }

    public static GraphqlErrorException genError(ErrorType errorType) {
        var err = GraphqlErrorException.newErrorException();
        switch (errorType) {
            case DUPLICATED_USER:
                err.message("existed username");
                break;
            case NO_USER:
                err.message("not exist username");
                break;
            case WRONG_PASSWORD:
                err.message("wrong password");
                break;
            case INVALID_TOKEN:
                err.message("invalid token");
                break;
            case FORBIDDEN:
                err.message("access forbidden");
                break;
            case NO_ITEM:
                err.message("no such query items");
                break;
        }

        err.extensions(updateErrorCode(errorType));
        return err.build();
    }
}