1. Introduction
GraphQL is a new concept devised by Facebook to design Web APIs. It is billed to be an alternative to widely used REST APIs.
Following are the major highlights of graphQL:
1. Home Page : https://graphql.org/
2. Github Repo : https://github.com/facebook/graphql
3. Predictable results : Send a GraphQL query to your API and get exactly what you need, nothing more and nothing less. GraphQL queries always return predictable results.
4. Unlike REST (where we have a end points per resource), application using graphQL exposes a single endpoint. Since GraphQL APIs are organized in terms of types and fields and not endpoints, it provides far more flexibility than traditional REST API.
5. GraphQL queries handle not just the attribute of one source but also simply follow source between them. GraphQL enables us to fetch data from nested data set in a single request (Eg: Get All Posts + latest comments associated with each post). This is achieved in REST, by calling multiple endpoints (1 endpoint to fetch posts + 1 endpoint to fetch comments from each post. This leaves us with hazardous n+1 fetching problem).
Before we dive further into GraphQL, let's discuss REST and the problems with it which GraphQL attempts to solve.
1.1 RESTful API
The Representational State Transfer (REST) architecture is the most popular way to expose server data over web.
In the RESTful architectural style, servers only offer resources. Resources are conceptual things about which clients and servers communicate (Eg User, ShoppingItem, Reviews, ShoppingCart are common resources in a typical shopping portal application). Each resource is identified by a URL.
REST uses standard HTTP verbs are used to perform actions on resources.
- 1. GET - retrieve a resource
- 2. PUT - update a resource
- 3. POST - create a new resource
- 4. DELETE - remove a resource
In RestFul API, resources can be represented in number of formats. Popular formats being Json, XML, RSS.
1.2 Problems with Restful API:
Under and Over Fetching data : In RESTful API, client has no control on the amount of data server sends for a resource, it can only ask for the resource to server.
Over Fetching means the client is retrieving data that is actually not needed at the moment when it’s being fetched. (Eg : while fetching User details, we might not want his address, but with REST we have no control on it)
Under Fetching is the opposite of overfetching and means that not enough data is included in an API response. (Eg : Get All Posts + latest comments associated with each post). This is achieved in REST, by calling multiple endpoints (1 endpoint to fetch posts + 1 endpoint to fetch comments from each post. This leaves us with hazardous n+1 fetching problem)
1.3 GraphQL Server
GraphQL solves this issue, as control rests with API client as what data it actually needs from server. It asks for specific fields and server happily obliges.
At the heart of any GraphQL implementation is graphQL schema, which is typically description of types of objects, relationships between them and further operations permitted on them (queries and mutations).
type Human {
id: String
name: String
homePlanet: String
}
Queries are commonly sent over HTTP to a specific server endpoint (unlike a REST architecture, where there are various endpoints for different solutions).
Type `Query` is used to expose the query operations on our schema
type Query {
human(id: String!): Human
}
The query sent to graphQL server is
POST /graphql?query={ human(id: "1") {id, name } }
This will return Human type with id=1.
Of these only attributes returned are id and name
Type ‘Mutation’ is typically used to modify the server side resources.
type Mutation {
addHuman(name: String!) : Human!
}
Similar to Query we can even define what fields we need from the return type of mutation
POST /graphql?mutation={ addHuman(name: "xyz") {id, name } }
This will add a human resource as well as return it.
Of these only attributes returned are id and name
2. GraphQL Server Application :
Lets try to build a simple API using Spring Boot and GraphQL
- 1. In our application the core entities are Article and Feedback. An article is similar to a user post and feedback are the replies/comments on it. Thus an article has many feedbacks.
- 2. Basic operations are get articles, feedbacks for an article, create article and create a feedback.
- 3. We are using MongoDb as the persistence layer for our application.
- 4. We will be using Spring boot starter for graphQL java implementation.
2.1 Application HLD
2.2 Project Structure
We will be creating a spring-boot maven project.
The project layout is as follows :
└───maven-project
├───pom.xml
└───src
├───main
│ ├───java (java source files)
│ ├───resources (properties files)
The project structure screenshot is as follows :
2.3 Application Code
2.3.1 Maven Dependencies :
At the very beginning our project has only pom.xml, with following content:
4.0.0
com.hemant.graphql
spring-boot-graphql
0.0.1-SNAPSHOT
jar
spring-boot-graphql
Demo project for Spring Boot
org.springframework.boot
spring-boot-starter-parent
1.5.1.RELEASE
UTF-8
UTF-8
1.8
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-data-mongodb
com.graphql-java
graphql-spring-boot-starter
4.0.0
com.graphql-java
graphql-java-tools
4.3.0
com.graphql-java
graphiql-spring-boot-starter
4.0.0
org.springframework.boot
spring-boot-starter-test
test
org.springframework.boot
spring-boot-maven-plugin
Explanation :
- graphql-spring-boot-starter - autoconfigure a GraphQL Servlet at /graphql
- graphql-java-tools - to parse GraphQL schemas
- graphiql-spring-boot-starter - we can use GraphiQL, an Electron app that you can use to test your GraphQL endpoint (/graphiql)
- spring-boot-starter-web - for enabling web MVC nature of spring boot app
- spring-boot-starter-data-mongodb - for interacting with MongoDB which is our persistence layer
2.3.2 Model classes :
As explained earlier, we have 2 model classes : Article and Feedback. An article can have multiple feedbacks (thus 1: many) relationship between them.
packagecom.hemant.graphql.model;
importjava.util.Date;
publicclassArticle {
private String id;
private String name;
private String createdByUserId;
private Date createdOn;
private Date lastUpdatedOn;
public String getId() {
return id;
}
publicvoidsetId(String id) {
this.id = id;
}
public String getName() {
return name;
}
publicvoidsetName(String name) {
this.name = name;
}
public String getCreatedByUserId() {
returncreatedByUserId;
}
publicvoidsetCreatedByUserId(String createdByUserId) {
this.createdByUserId = createdByUserId;
}
public Date getCreatedOn() {
returncreatedOn;
}
publicvoidsetCreatedOn(Date createdOn) {
this.createdOn = createdOn;
}
public Date getLastUpdatedOn() {
returnlastUpdatedOn;
}
publicvoidsetLastUpdatedOn(Date lastUpdatedOn) {
this.lastUpdatedOn = lastUpdatedOn;
}
@Override
publicinthashCode() {
finalint prime = 31;
int result = 1;
result = prime * result + ((id == null) ? 0 : id.hashCode());
return result;
}
@Override
publicbooleanequals(Object obj) {
if (this == obj)
returntrue;
if (obj == null)
returnfalse;
if (getClass() != obj.getClass())
returnfalse;
Article other = (Article) obj;
if (id == null) {
if (other.id != null)
returnfalse;
}
elseif(!id.equals(other.id))
returnfalse;
returntrue;
}
@Override
public String toString() {
return "Article [id=" + id + ", name=" + name + ", createdByUserId=" + createdByUserId + "]";
}
}
packagecom.hemant.graphql.model;
importjava.util.Date;
publicclassFeedback {
private String id;
private String feedbackText;
private String articleId;
private String createdByUserId;
private Date createdOn;
private Date lastUpdatedOn;
public String getId() {
return id;
}
publicvoidsetId(String id) {
this.id = id;
}
public String getFeedbackText() {
returnfeedbackText;
}
publicvoidsetFeedbackText(String feedbackText) {
this.feedbackText = feedbackText;
}
public String getArticleId() {
returnarticleId;
}
publicvoidsetArticleId(String articleId) {
this.articleId = articleId;
}
public String getCreatedByUserId() {
returncreatedByUserId;
}
publicvoidsetCreatedByUserId(String createdByUserId) {
this.createdByUserId = createdByUserId;
}
public Date getCreatedOn() {
returncreatedOn;
}
publicvoidsetCreatedOn(Date createdOn) {
this.createdOn = createdOn;
}
public Date getLastUpdatedOn() {
returnlastUpdatedOn;
}
publicvoidsetLastUpdatedOn(Date lastUpdatedOn) {
this.lastUpdatedOn = lastUpdatedOn;
}
@Override
publicinthashCode() {
finalint prime = 31;
int result = 1;
result = prime * result + ((id == null) ? 0 : id.hashCode());
return result;
}
@Override
publicbooleanequals(Object obj) {
if (this == obj)
returntrue;
if (obj == null)
returnfalse;
if (getClass() != obj.getClass())
returnfalse;
Feedback other = (Feedback) obj;
if (id == null) {
if (other.id != null)
returnfalse;
} elseif (!id.equals(other.id))
returnfalse;
returntrue;
}
@Override
public String toString() {
return"Feedback [id=" + id + ", feedbackText=" + feedbackText + ", articleId=" + articleId
+ ", createdByUserId=" + createdByUserId + "]";
}
}
In addition to it, we have also defined an enum for SortOrder.
packagecom.hemant.graphql.model.pagination;
publicenumSortOrder {
ASC, DESC;
}
2.3.3 GraphQL Schema :
We have devised our graphQL schema complimenting our models.
Also in schema we have defined the graphQL query and mutations.
The schema is defined as article.graphqls file in src/main/resources folder.
schema {
query: Query
mutation: Mutation
}
enumSortOrder {
ASC
DESC
}
type Article {
id: String
name: String
createdByUserId: String
createdOn: String
lastUpdatedOn: String
}
type Feedback {
id: String
feedbackText: String
articleId: String
createdByUserId: String
createdOn: String
lastUpdatedOn: String
}
type Query {
getAllArticles(pageNumber: Int!, pageSize : Int!, sortOrder: SortOrder!, sortBy: String!): [Article]
getFeedBacksForArticle(articleId: String!): [Feedback]
}
type Mutation {
createArticle(name: String!, createdByUserId: String!): Article
createNewFeedback(feedbackText: String!, articleId: String!, createdByUserId: String!): Feedback
}
2.3.4 Application properties file :
Spring boot gets its properties from default application.properties file in src/main/resources directory.
spring.application.name=spring-boot-graphql
server.port = 8080
logging.pattern.console=%d{HH:mm:ss} %-5level %logger{10} - %msg%n
logging.level.org.springframework=INFO
#mongoDB
app.mongodb.host=localhost
app.mongodb.port=27017
app.mongodb.database=graphql-demo
app.mongodb.username=root
app.mongodb.password=root
app.mongodb.authdb=admin
#GraphQL Properties
graphql.servlet.mapping=/graphql
graphql.servlet.enabled=true
graphql.servlet.corsEnabled=false
2.3.5 QueryResolver class
For all the queries defined in `Query` type in article.graphqls file, this class will provide its implementation.
type Query {
getAllArticles(pageNumber: Int!, pageSize : Int!, sortOrder: SortOrder!, sortBy: String!): [Article]
getFeedBacksForArticle(articleId: String!): [Feedback]
}
packagecom.hemant.graphql.resolvers;
importjava.util.List;
importorg.springframework.beans.factory.annotation.Autowired;
importorg.springframework.stereotype.Component;
importcom.coxautodev.graphql.tools.GraphQLQueryResolver;
importcom.hemant.graphql.model.Article;
importcom.hemant.graphql.model.Feedback;
importcom.hemant.graphql.model.pagination.SortOrder;
importcom.hemant.graphql.service.ArticleService;
@Component
publicclassQueryimplementsGraphQLQueryResolver {
@Autowired
privateArticleServicearticleService;
public ListgetAllArticles(intpageNumber, intpageSize, SortOrdersortOrder, String sortBy) {
returnarticleService.getAllArticles(pageNumber, pageSize, sortOrder, sortBy);
}
public ListgetFeedBacksForArticle(String articleId) {
returnarticleService.getFeedbacksForArticle(articleId);
}
}
2.3.6 Mutation Resolver :
For all the mutations defined in `Mutation` type in article.graphqls file, this class provides its implementation.
type Mutation {
createArticle(name: String!, createdByUserId: String!): Article
createNewFeedback(feedbackText: String!, articleId: String!, createdByUserId: String!): Feedback
}
packagecom.hemant.graphql.resolvers;
importorg.springframework.beans.factory.annotation.Autowired;
importorg.springframework.stereotype.Component;
importcom.coxautodev.graphql.tools.GraphQLMutationResolver;
importcom.hemant.graphql.model.Article;
importcom.hemant.graphql.model.Feedback;
importcom.hemant.graphql.service.ArticleService;
@Component
publicclassMutationimplementsGraphQLMutationResolver {
@Autowired
privateArticleServicearticleService;
public Article createArticle(String name, String createdByUserId) {
returnarticleService.createArticle(name, createdByUserId);
}
public Feedback createNewFeedback(String feedbackText, String articleId,StringcreatedByUserId) {
returnarticleService.createFeedback(feedbackText, articleId, createdByUserId);
}
}
2.3.7 Service Layer
This layer defines the business rules/logic.
packagecom.hemant.graphql.service;
importjava.util.List;
importcom.hemant.graphql.model.Article;
importcom.hemant.graphql.model.Feedback;
importcom.hemant.graphql.model.pagination.SortOrder;
publicinterfaceArticleService {
ListgetAllArticles(intpageNumber, intpageSize, SortOrdersortOrder, String sortBy);
Article createArticle(String name, String createdByUserId);
ListgetFeedbacksForArticle(String articleId);
Feedback createFeedback(String feedbackText, String articleId, String createdByUserId);
}
packagecom.hemant.graphql.service;
importstatic org.apache.commons.lang3.StringUtils.isBlank;
importstatic org.apache.commons.lang3.StringUtils.isNotBlank;
importjava.util.ArrayList;
importjava.util.Date;
importjava.util.List;
import org.apache.commons.lang3.StringUtils;
importorg.bson.types.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
importorg.springframework.beans.factory.annotation.Autowired;
importorg.springframework.stereotype.Service;
importorg.springframework.util.CollectionUtils;
importcom.hemant.graphql.dal.ArticleDAL;
importcom.hemant.graphql.model.Article;
importcom.hemant.graphql.model.Feedback;
importcom.hemant.graphql.model.pagination.SortOrder;
@Service
publicclassArticleServiceImplimplementsArticleService {
@Autowired
privateArticleDALarticleDAL;
privatestaticfinal Logger LOG = LoggerFactory.getLogger(ArticleServiceImpl.class);
@Override
public ListgetAllArticles(intpageNumber, intpageSize, SortOrdersortOrder, String sortBy) {
String validationErrors = validatePaginationParams(pageNumber, pageSize, sortOrder, sortBy);
if(isNotBlank(validationErrors)) {
thrownewIllegalArgumentException(validationErrors);
}
returnarticleDAL.getAllArticles(pageNumber, pageSize, sortOrder, sortBy);
}
@Override
public Article createArticle(String name, String createdByUserId) {
if(isBlank(name)) {
thrownewIllegalArgumentException("Article name cannot be blank");
}
if(isBlank(createdByUserId)) {
thrownewIllegalArgumentException("CreatedByUserId cannot be blank");
}
String id = newObjectId().toString();
Article art = new Article();
art.setId(id);
art.setCreatedByUserId(createdByUserId);
art.setName(name);
art.setCreatedOn(new Date());
art.setLastUpdatedOn(new Date());
articleDAL.saveArticle(art);
LOG.info("Article created successfully :{}", art);
return art;
}
@Override
public ListgetFeedbacksForArticle(String articleId) {
returnarticleDAL.getFeedbacksForArticle(articleId);
}
@Override
public Feedback createFeedback(String feedbackText, String articleId, String createdByUserId) {
if(isBlank(feedbackText)) {
thrownewIllegalArgumentException("FeedbackText name cannot be blank");
}
if(isBlank(createdByUserId)) {
thrownewIllegalArgumentException("CreatedByUserId cannot be blank");
}
Article article = articleDAL.getArticleById(articleId);
if(null == article) {
LOG.error("No article exists for articleId :{}", articleId);
thrownewIllegalArgumentException("No article exists for articleId :" + articleId);
}
Feedback feedback = new Feedback();
feedback.setArticleId(articleId);
feedback.setCreatedByUserId(createdByUserId);
feedback.setFeedbackText(feedbackText);
feedback.setCreatedOn(new Date());
feedback.setLastUpdatedOn(new Date());
articleDAL.saveFeedback(feedback);
LOG.info("Feedback created as :{} for article :{}", feedback, article);
return feedback;
}
public String validatePaginationParams(intpageNumber, intpageSize, SortOrdersortOrder, String sortBy) {
ListvalidationErrors = new ArrayList<>();
if (pageNumber<0) { LOG.error("Minimum PageNumber=0. PageNumber :{} must be greater than 0", pageNumber); validationErrors.add("PageNumber must be greater than or equal to 0!"); } if (pageSize<1) { LOG.error("PageSize :{} must be greater than 0", pageSize); validationErrors.add("PageSize must be greater than 0"); } if (null==sortOrder) { LOG.error("SortOrder :{} must be specified", sortOrder); validationErrors.add("SortOrder must be specified"); } if (StringUtils.isBlank(sortBy)) { LOG.error("SortBy :{} must be specified", sortBy); validationErrors.add("SortBy must be specified"); } if (CollectionUtils.isEmpty(validationErrors)) { returnnull; } returnStringUtils.join(validationErrors, "/n" ); } } 2.3.8 Data Access layer
This layer defines the interaction with database ieMongoDB.
packagecom.hemant.graphql.dal;
importjava.util.List;
importcom.hemant.graphql.model.Article;
importcom.hemant.graphql.model.Feedback;
importcom.hemant.graphql.model.pagination.SortOrder;
publicinterfaceArticleDAL {
ListgetAllArticles(intpageNumber, intpageSize, SortOrdersortOrder, String sortBy);
voidsaveArticle(Article art);
ListgetFeedbacksForArticle(String articleId);
Article getArticleById(String articleId);
voidsaveFeedback(Feedback feedback);
}
packagecom.hemant.graphql.dal;
importjava.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
importorg.springframework.beans.factory.annotation.Autowired;
importorg.springframework.data.domain.Sort;
importorg.springframework.data.mongodb.core.MongoTemplate;
importorg.springframework.data.mongodb.core.query.Criteria;
importorg.springframework.data.mongodb.core.query.Query;
importorg.springframework.stereotype.Repository;
importcom.hemant.graphql.model.Article;
importcom.hemant.graphql.model.Feedback;
importcom.hemant.graphql.model.pagination.SortOrder;
@Repository
publicclassArticleDALImplimplementsArticleDAL {
privatestaticfinal Logger LOG = LoggerFactory.getLogger(ArticleDALImpl.class);
@Autowired
privateMongoTemplate mongo;
@Override
public ListgetAllArticles(intpageNumber, intpageSize, SortOrdersortOrder, String sortBy) {
finalint limit = pageSize;
finalint offset = pageNumber * pageSize;
Query query = new Query();
query.skip(offset).limit(limit).with(new Sort(Sort.Direction.fromString(sortOrder.toString()), sortBy));
LOG.info("The pagination query is limit:{}, offset:{}, sort:{}", query.getLimit(), query.getSkip(),
query.getSortObject());
returnmongo.find(query, Article.class);
}
@Override
publicvoidsaveArticle(Article art) {
mongo.save(art);
}
@Override
public ListgetFeedbacksForArticle(String articleId) {
Query query = new Query();
query.addCriteria(Criteria.where("articleId").is(articleId));
returnmongo.find(query, Feedback.class);
}
@Override
public Article getArticleById(String articleId) {
returnmongo.findById(articleId, Article.class);
}
@Override
publicvoidsaveFeedback(Feedback fb) {
mongo.save(fb);
}
}
2.3.9 Spring Boot Starter class:
The final piece of puzzle is main class for spring boot.
packagecom.hemant.graphql;
importorg.springframework.boot.SpringApplication;
importorg.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
publicclassSpringBootGraphqlApp {
publicstaticvoidmain(String[] args) {
SpringApplication.run(SpringBootGraphqlApp.class, args);
}
}
3. Executing The Project
3.1 Running the application
1. Run the main application class (SpringBootGraphqlApp.java) as Java web development in IDE (Eclipse/IntelliJ) OR
2. If you want to run it from terminal, then
- Go to root directory, execute “mvn clean install”. This will create the jar in /target child directory.
- Execute the jar as “java -jar target/spring-boot-graphql-0.0.1-SNAPSHOT.jar”
3. Once the application is started, its endpoints can be called.
4. Once application is started, you can go to “ http://localhost:8080/graphiql” in your browser. This will launch graphiql - an electron application to test our graphQL endpoints. This comes with content-assist, auto-complete for building quick building of queries and mutations.
3.2 Sample User Flow
3.2.1 Let’s start by creating a few Articles.
Here we have asked only id, name, createdByUserId and createdOn attributes.
3.2.2 Now lets create 1 more article, now asking for only id, name
3.2.3 Lets list all the articles :
query{
getAllArticles(pageNumber:0, pageSize:5, sortOrder:ASC, sortBy: "name"){
id,
name,
createdOn
}
}
The response is :
{
"data": {
"getAllArticles": [
{
"id": "5b2a4cbb7ae2648fbaa2ce9e",
"name": "A1",
"createdOn": "Wed Jun 20 18:16:51 IST 2018"
},
{
"id": "5b2a4d417ae2648fbaa2ce9f",
"name": "A2",
"createdOn": "Wed Jun 20 18:19:05 IST 2018"
}
]
}
}
As predicted, we only got id, name and createdOn for each article fetched.
3.2.4 Lets create a feedback against an article :
mutation {
createNewFeedback(feedbackText: "fB1", articleId : "5b2a4cbb7ae2648fbaa2ce9e", createdByUserId:"1") {
id
}
}
Result is :
{
"data": {
"createNewFeedback": {
"id": "5b2a4e1d7ae2648fbaa2cea0"
}
}
}
3.2.5 List Feedbacks :
After creating a few more feedbacks, lets list the feedbacks for an article
query{
getFeedBacksForArticle(articleId:"5b2a4cbb7ae2648fbaa2ce9e") {
id
feedbackText
}
}
{
"data": {
"getFeedBacksForArticle": [
{
"id": "5b2a4e1d7ae2648fbaa2cea0",
"feedbackText": "fB1"
},
{
"id": "5b2a4e577ae2648fbaa2cea1",
"feedbackText": "fB2"
},
{
"id": "5b2a4e5a7ae2648fbaa2cea2",
"feedbackText": "fB2"
},
{
"id": "5b2a4e5a7ae2648fbaa2cea3",
"feedbackText": "fB2"
},
{
"id": "5b2a4e5a7ae2648fbaa2cea4",
"feedbackText": "fB2"
},
{
"id": "5b2a4e5b7ae2648fbaa2cea5",
"feedbackText": "fB2"
},
{
"id": "5b2a4e5b7ae2648fbaa2cea6",
"feedbackText": "fB2"
},
{
"id": "5b2a4e5b7ae2648fbaa2cea7",
"feedbackText": "fB2"
},
{
"id": "5b2a4e5b7ae2648fbaa2cea8",
"feedbackText": "fB2"
},
{
"id": "5b2a4e5b7ae2648fbaa2cea9",
"feedbackText": "fB2"
}
]
}
}
Conclusion
Thus we have tested the graphQL application and its end-points using graphIQL interface.
We have queried the graphQL schema, using query and verified that it returns only those attributes which client explicitly asks in API.
We also performed server changes, using mutations. Again we verified that it returns only those attributes which client explicitly asks in API.
This is quite a different API building experience than typical REST APIs.