March 5, 2024
14 min read
Mastering GraphQL: From REST to Modern APIs
GraphQL
API
React
Apollo
GraphQL is revolutionizing how we think about APIs. Unlike REST, GraphQL allows clients to request exactly the data they need, reducing over-fetching and improving performance.
Why GraphQL Over REST?
Problems with REST
- Over-fetching: Getting more data than needed
- Under-fetching: Multiple requests for related data
- Versioning issues: Breaking changes require new endpoints
- Documentation drift: API docs become outdated
GraphQL Advantages
- Single endpoint: One URL for all operations
- Type safety: Strong schema definition
- Efficient: Request only needed data
- Self-documenting: Schema serves as documentation
- Real-time: Built-in subscription support
Setting Up a GraphQL Server
With Apollo Server (Node.js)
npm install apollo-server-express graphql
// server.js
const { ApolloServer, gql } = require('apollo-server-express');
const express = require('express');
// Type definitions
const typeDefs = gql`
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
published: Boolean!
tags: [String!]!
createdAt: String!
updatedAt: String!
}
type Query {
users: [User!]!
user(id: ID!): User
posts: [Post!]!
post(id: ID!): Post
searchPosts(query: String!): [Post!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
type Subscription {
postAdded: Post!
postUpdated: Post!
userOnline(userId: ID!): User!
}
input CreateUserInput {
name: String!
email: String!
}
input UpdateUserInput {
name: String
email: String
}
input CreatePostInput {
title: String!
content: String!
authorId: ID!
tags: [String!]
}
input UpdatePostInput {
title: String
content: String
published: Boolean
tags: [String!]
}
`;
// Resolvers
const resolvers = {
Query: {
users: async () => {
return await User.findAll();
},
user: async (_, { id }) => {
return await User.findByPk(id);
},
posts: async () => {
return await Post.findAll();
},
post: async (_, { id }) => {
return await Post.findByPk(id);
},
searchPosts: async (_, { query }) => {
return await Post.findAll({
where: {
[Op.or]: [
{ title: { [Op.iLike]: `%${query}%` } },
{ content: { [Op.iLike]: `%${query}%` } }
]
}
});
}
},
Mutation: {
createUser: async (_, { input }) => {
return await User.create(input);
},
updateUser: async (_, { id, input }) => {
await User.update(input, { where: { id } });
return await User.findByPk(id);
},
deleteUser: async (_, { id }) => {
const result = await User.destroy({ where: { id } });
return result > 0;
},
createPost: async (_, { input }, { pubsub }) => {
const post = await Post.create(input);
pubsub.publish('POST_ADDED', { postAdded: post });
return post;
},
updatePost: async (_, { id, input }, { pubsub }) => {
await Post.update(input, { where: { id } });
const post = await Post.findByPk(id);
pubsub.publish('POST_UPDATED', { postUpdated: post });
return post;
},
deletePost: async (_, { id }) => {
const result = await Post.destroy({ where: { id } });
return result > 0;
}
},
Subscription: {
postAdded: {
subscribe: (_, __, { pubsub }) => pubsub.asyncIterator(['POST_ADDED'])
},
postUpdated: {
subscribe: (_, __, { pubsub }) => pubsub.asyncIterator(['POST_UPDATED'])
},
userOnline: {
subscribe: (_, { userId }, { pubsub }) =>
pubsub.asyncIterator([`USER_ONLINE_${userId}`])
}
},
// Field resolvers
User: {
posts: async (parent) => {
return await Post.findAll({ where: { authorId: parent.id } });
}
},
Post: {
author: async (parent) => {
return await User.findByPk(parent.authorId);
}
}
};
async function startServer() {
const app = express();
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req, connection }) => {
if (connection) {
return connection.context;
}
return {
user: req.user, // from auth middleware
pubsub: new PubSub()
};
},
subscriptions: {
onConnect: (connectionParams, webSocket) => {
console.log('Client connected');
return { user: connectionParams.user };
},
onDisconnect: () => {
console.log('Client disconnected');
}
}
});
await server.start();
server.applyMiddleware({ app });
const httpServer = createServer(app);
server.installSubscriptionHandlers(httpServer);
httpServer.listen(4000, () => {
console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`);
console.log(`🚀 Subscriptions ready at ws://localhost:4000${server.subscriptionsPath}`);
});
}
startServer();
GraphQL Client with React and Apollo
Setup Apollo Client
npm install @apollo/client graphql
// apollo-client.js
import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
import { WebSocketLink } from '@apollo/client/link/ws';
const httpLink = new HttpLink({
uri: 'http://localhost:4000/graphql'
});
const wsLink = new WebSocketLink({
uri: 'ws://localhost:4000/graphql',
options: {
reconnect: true,
connectionParams: {
authToken: localStorage.getItem('token'),
}
}
});
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache({
typePolicies: {
Post: {
fields: {
comments: {
merge(existing = [], incoming) {
return [...existing, ...incoming];
}
}
}
}
}
})
});
export default client;
Using Queries and Mutations
// components/PostList.js
import { useQuery, useMutation, useSubscription } from '@apollo/client';
import { gql } from '@apollo/client';
const GET_POSTS = gql`
query GetPosts {
posts {
id
title
content
published
author {
id
name
}
tags
createdAt
}
}
`;
const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
content
author {
name
}
}
}
`;
const POST_ADDED_SUBSCRIPTION = gql`
subscription PostAdded {
postAdded {
id
title
content
author {
name
}
}
}
`;
function PostList() {
const { loading, error, data, refetch } = useQuery(GET_POSTS, {
pollInterval: 30000, // Poll every 30 seconds
errorPolicy: 'partial'
});
const [createPost, { loading: creating }] = useMutation(CREATE_POST, {
update(cache, { data: { createPost } }) {
const { posts } = cache.readQuery({ query: GET_POSTS });
cache.writeQuery({
query: GET_POSTS,
data: { posts: [createPost, ...posts] }
});
},
onError: (error) => {
console.error('Error creating post:', error);
}
});
useSubscription(POST_ADDED_SUBSCRIPTION, {
onSubscriptionData: ({ subscriptionData }) => {
const newPost = subscriptionData.data.postAdded;
console.log('New post added:', newPost);
}
});
const handleCreatePost = async (formData) => {
try {
await createPost({
variables: {
input: {
title: formData.title,
content: formData.content,
authorId: formData.authorId,
tags: formData.tags
}
}
});
} catch (error) {
console.error('Failed to create post:', error);
}
};
if (loading) return <div>Loading posts...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>Posts</h2>
{data?.posts.map(post => (
<div key={post.id} className="post-card">
<h3>{post.title}</h3>
<p>{post.content}</p>
<small>By {post.author.name}</small>
<div className="tags">
{post.tags.map(tag => (
<span key={tag} className="tag">{tag}</span>
))}
</div>
</div>
))}
</div>
);
}
export default PostList;
Advanced GraphQL Patterns
DataLoader for N+1 Problem
// dataloaders.js
const DataLoader = require('dataloader');
const createUserLoader = () => new DataLoader(async (userIds) => {
const users = await User.findAll({
where: { id: userIds }
});
return userIds.map(id =>
users.find(user => user.id === id)
);
});
const createPostsByUserLoader = () => new DataLoader(async (userIds) => {
const posts = await Post.findAll({
where: { authorId: userIds }
});
return userIds.map(userId =>
posts.filter(post => post.authorId === userId)
);
});
// Use in resolvers
User: {
posts: async (parent, _, { dataloaders }) => {
return await dataloaders.postsByUser.load(parent.id);
}
}
Custom Directives
const { SchemaDirectiveVisitor } = require('apollo-server-express');
class AuthDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
const { resolve = defaultFieldResolver } = field;
const requiredRole = this.args.requires;
field.resolve = async function (...args) {
const [, , context] = args;
const { user } = context;
if (!user) {
throw new AuthenticationError('You must be logged in');
}
if (requiredRole && user.role !== requiredRole) {
throw new ForbiddenError('Insufficient permissions');
}
return resolve.apply(this, args);
};
}
}
// In schema
const typeDefs = gql`
directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role {
ADMIN
USER
}
type Query {
users: [User!]! @auth(requires: ADMIN)
me: User @auth
}
`;
Error Handling
const { ApolloError } = require('apollo-server-express');
class ValidationError extends ApolloError {
constructor(message, field) {
super(message, 'VALIDATION_ERROR', { field });
}
}
// In resolver
createUser: async (_, { input }) => {
if (!input.email.includes('@')) {
throw new ValidationError('Invalid email format', 'email');
}
try {
return await User.create(input);
} catch (error) {
if (error.name === 'SequelizeUniqueConstraintError') {
throw new ValidationError('Email already exists', 'email');
}
throw error;
}
}
Testing GraphQL APIs
Testing with Jest
// tests/graphql.test.js
const { createTestClient } = require('apollo-server-testing');
const { gql } = require('apollo-server-express');
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`;
const CREATE_USER = gql`
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
`;
describe('GraphQL API', () => {
let query, mutate;
beforeEach(() => {
const { query: q, mutate: m } = createTestClient(server);
query = q;
mutate = m;
});
it('should fetch users', async () => {
const res = await query({ query: GET_USERS });
expect(res.errors).toBeUndefined();
expect(res.data.users).toHaveLength(2);
expect(res.data.users[0]).toHaveProperty('name');
});
it('should create a user', async () => {
const res = await mutate({
mutation: CREATE_USER,
variables: {
input: {
name: 'John Doe',
email: 'john@example.com'
}
}
});
expect(res.errors).toBeUndefined();
expect(res.data.createUser.name).toBe('John Doe');
});
});
Performance Optimization
Query Complexity Analysis
const costAnalysis = require('graphql-cost-analysis');
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
costAnalysis({
maximumCost: 1000,
defaultCost: 1,
createError: (max, actual) => {
return new Error(
`Query exceeded maximum cost of ${max}. Actual cost: ${actual}`
);
}
})
]
});
Caching with Redis
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
const resolvers = {
Query: {
posts: async () => {
const cacheKey = 'posts:all';
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const posts = await Post.findAll();
await redis.setex(cacheKey, 300, JSON.stringify(posts)); // Cache for 5 minutes
return posts;
}
}
};
Conclusion
GraphQL provides a powerful alternative to REST APIs with its flexible query language, strong typing, and real-time capabilities. By implementing proper patterns for data loading, authentication, caching, and error handling, you can build robust and efficient APIs that scale with your application needs.
Start with simple queries and mutations, then gradually add advanced features like subscriptions, custom directives, and performance optimizations as your application grows.