Oreoluwa
Mastering GraphQL: From REST to Modern APIs
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.