Spring - GraphQL

Overview

GraphQL is a query language that offers an alternative model to developing APIs instead of REST, SOAP or gRPC. It allows partial fetch of data, you can use a single endpoint to fetch different formats of data.

Github: https://github.com/gitorko/project96

Spring Boot GraphQL

Lets say you have a rest api that returns customer profile, the customer profile has 200+ fields, so a mobile device may not need all the fields, it may need may be 5 fields like name, address etc. Requesting a big payload over wire is costly. So now you end up writing a rest endpoint that returns just the 5 fields. This can become overwhelming when the requirements increase and you end up creating different endpoint for such requirement. In GraphQL you define a schema and let the user/consumer decide which fields they want to fetch.

Before GraphQL 1.0 was Released spring had to extend the classes GraphQLMutationResolver, GraphQLQueryResolver. Its no longer required.

GraphQLMutationResolver -> @MutationMapping

GraphQLQueryResolver -> @QueryMapping

The code uses Extended Scalars for graphql-java to support Date and other type objects in GraphQL The code shows how pagination can be done in GraphQL

Code

 1package com.demo.project96.controller;
 2
 3import java.util.Optional;
 4
 5import com.demo.project96.domain.Comment;
 6import com.demo.project96.domain.Post;
 7import com.demo.project96.domain.PostPage;
 8import com.demo.project96.repo.CommentRepository;
 9import com.demo.project96.repo.PostRepository;
10import lombok.RequiredArgsConstructor;
11import lombok.extern.slf4j.Slf4j;
12import org.springframework.data.domain.Page;
13import org.springframework.data.domain.PageRequest;
14import org.springframework.graphql.data.method.annotation.Argument;
15import org.springframework.graphql.data.method.annotation.QueryMapping;
16import org.springframework.graphql.data.method.annotation.SchemaMapping;
17import org.springframework.stereotype.Controller;
18
19@Controller
20@Slf4j
21@RequiredArgsConstructor
22public class QueryController {
23
24    private final PostRepository postRepository;
25    private final CommentRepository commentRepository;
26
27    @QueryMapping
28    public Iterable<Post> findAllPosts() {
29        return postRepository.findAll();
30    }
31
32    @QueryMapping
33    public PostPage findAllPostsPage(@Argument Integer page, @Argument Integer size) {
34        PageRequest pageOf = PageRequest.of(page, size);
35        Page<Post> all = postRepository.findAll(pageOf);
36        return PostPage.builder()
37                .posts(all.getContent())
38                .totalElements(all.getTotalElements())
39                .totalPages(all.getTotalPages())
40                .currentPage(all.getNumber())
41                .size(all.getNumberOfElements())
42                .build();
43    }
44
45    @QueryMapping
46    public Optional<Post> findPostById(@Argument("id") Long id) {
47        return postRepository.findById(id);
48    }
49
50    @QueryMapping
51    public Iterable<Comment> findAllComments() {
52        //Will cause N+1 problem
53        //return commentRepository.findAll();
54        return commentRepository.findAllComments();
55    }
56
57    @QueryMapping
58    public Optional<Comment> findCommentById(@Argument("id") Long id) {
59        return commentRepository.findById(id);
60    }
61
62    @QueryMapping
63    public long countPosts() {
64        return postRepository.count();
65    }
66
67    @QueryMapping
68    public Iterable<Comment> findCommentsByPostId(@Argument("postId") Long postId) {
69        Optional<Post> byId = postRepository.findById(postId);
70        if (byId.isPresent()) {
71            return commentRepository.findByPost(byId.get());
72        } else {
73            throw new RuntimeException("Post not found!");
74        }
75    }
76
77    /**
78     * Functionality will work same without this method as well.
79     * Hibernate Lazy fetch prevents the post entity from being fetched even without this method.
80     * So no unnecessary db call is made if post entity is not needed in the response even without this method.
81     * However if there is any reason why we want to control a single field explicitly we can use this approach and define how that field gets data.
82     * eg: You want to sort the comments
83     */
84    @SchemaMapping(typeName = "Comment", field = "post")
85    public Post getPost(Comment comment) {
86        return postRepository.findById(comment.getPost().getId()).orElseThrow(null);
87    }
88
89}
 1package com.demo.project96.controller;
 2
 3import java.time.ZonedDateTime;
 4import java.util.Optional;
 5
 6import com.demo.project96.domain.Comment;
 7import com.demo.project96.domain.Post;
 8import com.demo.project96.repo.CommentRepository;
 9import com.demo.project96.repo.PostRepository;
10import lombok.RequiredArgsConstructor;
11import lombok.extern.slf4j.Slf4j;
12import org.springframework.graphql.data.method.annotation.Argument;
13import org.springframework.graphql.data.method.annotation.MutationMapping;
14import org.springframework.stereotype.Controller;
15
16@Controller
17@Slf4j
18@RequiredArgsConstructor
19public class MutationController {
20
21    private final PostRepository postRepository;
22    private final CommentRepository commentRepository;
23
24    @MutationMapping
25    public Post createPost(@Argument("header") String header, @Argument("createdBy") String createdBy) {
26        Post post = new Post();
27        post.setHeader(header);
28        post.setCreatedBy(createdBy);
29        post.setCreatedDt(ZonedDateTime.now());
30        post = postRepository.save(post);
31        return post;
32    }
33
34    @MutationMapping
35    public Comment createComment(@Argument("message") String message, @Argument("createdBy") String createdBy, @Argument("postId") Long postId) {
36        Comment comment = new Comment();
37        Optional<Post> byId = postRepository.findById(postId);
38        if (byId.isPresent()) {
39            Post post = byId.get();
40            comment.setPost(post);
41            comment.setMessage(message);
42            comment.setCreatedBy(createdBy);
43            comment.setCreatedDt(ZonedDateTime.now());
44            comment = commentRepository.save(comment);
45            return comment;
46        } else {
47            throw new RuntimeException("Post not found!");
48        }
49
50    }
51
52    @MutationMapping
53    public boolean deleteComment(@Argument("id") Long id) {
54        commentRepository.deleteById(id);
55        return true;
56    }
57
58    @MutationMapping
59    public Comment updateComment(@Argument("id") Long id, @Argument("message") String message) {
60        Optional<Comment> byId = commentRepository.findById(id);
61        if (byId.isPresent()) {
62            Comment comment = byId.get();
63            comment.setMessage(message);
64            commentRepository.save(comment);
65            return comment;
66        }
67        throw new RuntimeException("Post not found!");
68    }
69}

The schema for GraphQL. The ! simply tells us that you can always expect a value back and will never need to check for null.

 1scalar DateTime
 2
 3type Post {
 4    id: ID!
 5    header: String!
 6    createdDt: DateTime!
 7    createdBy: String!
 8}
 9
10type PostPage {
11    posts: [Post]
12    totalElements: Int
13    totalPages: Int
14    currentPage: Int
15    size: Int
16}
17
18type Query {
19    findAllPosts: [Post]
20    findPostById(id: ID!): Post
21    countPosts: Int!
22    findAllPostsPage(page: Int = 0, size: Int = 20): PostPage
23}
24
25type Mutation {
26    createPost(header: String!, createdBy: String!): Post
27}

GraphQL accepts only one root Query and one root Mutation types, To keep the logic in different files we extend the Query and Mutation types.

 1type Comment {
 2    id: ID!
 3    message: String!
 4    createdBy: String!
 5    createdDt: DateTime!
 6    post: Post
 7}
 8
 9extend type Query {
10    findAllComments: [Comment]!
11    findCommentById(id: ID!): Comment!
12    findCommentsByPostId(postId: ID!): [Comment]
13}
14
15extend type Mutation {
16    createComment(message: String!, createdBy: String!, postId: ID!): Comment!
17    updateComment(id: ID!, message: String!): Comment!
18    deleteComment(id: ID!): Boolean
19}

The key terminologies in GraphQL are

  • Query: Used to read data
  • Mutation: Used to create, update and delete data
  • Subscription: Similar to a query allowing you to fetch data from the server. Subscriptions offer a long-lasting operation that can change their result over time.

Setup

GraphQL

https://gitorko.github.io/spring-graphql/

Spring Boot & GraphQL

Version

Check version

1$java --version
2openjdk 17.0.3 2022-04-19 LTS
3
4node --version
5v16.16.0
6
7yarn --version
81.22.18

Postgres DB

1docker run -p 5432:5432 --name pg-container -e POSTGRES_PASSWORD=password -d postgres:9.6.10
2docker ps
3docker exec -it pg-container psql -U postgres -W postgres
4CREATE USER test WITH PASSWORD 'test@123';
5CREATE DATABASE "test-db" WITH OWNER "test" ENCODING UTF8 TEMPLATE template0;
6grant all PRIVILEGES ON DATABASE "test-db" to test;
7
8docker stop pg-container
9docker start pg-container

Dev

To run the backend in dev mode. Postgres DB is needed to run the integration tests during build.

1./gradlew clean build
2./gradlew bootRun

Prod

To run as a single jar.

1./gradlew bootJar
2cd project96/build/libs
3java -jar project96-1.0.0.jar

Graph IQL

GraphQL comes with a browser client to test the Query. This can be enabled in properties

1graphql.graphiql.enabled: true

Open http://localhost:8080/graphiql

Postman

Import the postman collection to postman

Postman Collection

Testing

References

https://spring.io/projects/spring-graphql

https://github.com/graphql-java/graphql-java-extended-scalars

https://www.graphql-java.com/tutorials/getting-started-with-spring-boot/

https://spring.io/blog/2022/05/19/spring-for-graphql-1-0-release

comments powered by Disqus