Building GraphQL APIs with Node.js

Last Modified: December 31, 2024

Master the art of building robust GraphQL APIs with Node.js and Apollo Server. Learn best practices, security considerations, and performance optimization techniques. 🚀

Project Setup 🎯

# Initialize project
mkdir graphql-api
cd graphql-api
npm init -y

# Install dependencies
npm install @apollo/server graphql
npm install mongoose jsonwebtoken bcryptjs
npm install -D typescript @types/node ts-node-dev
npm install -D @types/jsonwebtoken @types/bcryptjs

Schema Definition 📝

// src/schema/typeDefs.ts
const typeDefs = `#graphql
  type User {
    id: ID!
    email: String!
    name: String!
    posts: [Post!]!
    createdAt: String!
    updatedAt: String!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
    comments: [Comment!]!
    createdAt: String!
    updatedAt: String!
  }

  type Comment {
    id: ID!
    content: String!
    author: User!
    post: Post!
    createdAt: String!
    updatedAt: String!
  }

  type AuthPayload {
    token: String!
    user: User!
  }

  input CreateUserInput {
    email: String!
    password: String!
    name: String!
  }

  input LoginInput {
    email: String!
    password: String!
  }

  input CreatePostInput {
    title: String!
    content: String!
  }

  input CreateCommentInput {
    content: String!
    postId: ID!
  }

  type Query {
    me: User!
    user(id: ID!): User
    users: [User!]!
    post(id: ID!): Post
    posts: [Post!]!
    comments(postId: ID!): [Comment!]!
  }

  type Mutation {
    createUser(input: CreateUserInput!): AuthPayload!
    login(input: LoginInput!): AuthPayload!
    createPost(input: CreatePostInput!): Post!
    createComment(input: CreateCommentInput!): Comment!
    deletePost(id: ID!): Boolean!
    deleteComment(id: ID!): Boolean!
  }

  type Subscription {
    postCreated: Post!
    commentCreated(postId: ID!): Comment!
  }
`;

export default typeDefs;

Resolvers Implementation 🔧

// src/resolvers/index.ts
import { IResolvers } from '@graphql-tools/utils';
import { AuthenticationError, UserInputError } from '@apollo/server';
import { User, Post, Comment } from '../models';
import { generateToken, hashPassword, comparePasswords } from '../utils/auth';
import { pubsub } from '../utils/pubsub';

const resolvers: IResolvers = {
  Query: {
    me: async (_, __, { user }) => {
      if (!user) throw new AuthenticationError('Not authenticated');
      return user;
    },

    user: async (_, { id }) => {
      const user = await User.findById(id);
      if (!user) throw new UserInputError('User not found');
      return user;
    },

    users: async () => {
      return User.find({});
    },

    post: async (_, { id }) => {
      const post = await Post.findById(id);
      if (!post) throw new UserInputError('Post not found');
      return post;
    },

    posts: async () => {
      return Post.find({}).sort({ createdAt: -1 });
    },

    comments: async (_, { postId }) => {
      return Comment.find({ post: postId }).sort({ createdAt: -1 });
    }
  },

  Mutation: {
    createUser: async (_, { input }) => {
      const { email, password, name } = input;

      const existingUser = await User.findOne({ email });
      if (existingUser) {
        throw new UserInputError('Email already taken');
      }

      const hashedPassword = await hashPassword(password);
      const user = await User.create({
        email,
        password: hashedPassword,
        name
      });

      const token = generateToken(user);
      return { token, user };
    },

    login: async (_, { input }) => {
      const { email, password } = input;

      const user = await User.findOne({ email });
      if (!user) {
        throw new UserInputError('Invalid credentials');
      }

      const isValid = await comparePasswords(password, user.password);
      if (!isValid) {
        throw new UserInputError('Invalid credentials');
      }

      const token = generateToken(user);
      return { token, user };
    },

    createPost: async (_, { input }, { user }) => {
      if (!user) throw new AuthenticationError('Not authenticated');

      const post = await Post.create({
        ...input,
        author: user.id
      });

      pubsub.publish('POST_CREATED', { postCreated: post });
      return post;
    },

    createComment: async (_, { input }, { user }) => {
      if (!user) throw new AuthenticationError('Not authenticated');

      const { content, postId } = input;
      const post = await Post.findById(postId);
      if (!post) throw new UserInputError('Post not found');

      const comment = await Comment.create({
        content,
        author: user.id,
        post: postId
      });

      pubsub.publish('COMMENT_CREATED', {
        commentCreated: comment,
        postId
      });

      return comment;
    },

    deletePost: async (_, { id }, { user }) => {
      if (!user) throw new AuthenticationError('Not authenticated');

      const post = await Post.findById(id);
      if (!post) throw new UserInputError('Post not found');

      if (post.author.toString() !== user.id) {
        throw new AuthenticationError('Not authorized');
      }

      await Post.deleteOne({ _id: id });
      await Comment.deleteMany({ post: id });
      return true;
    },

    deleteComment: async (_, { id }, { user }) => {
      if (!user) throw new AuthenticationError('Not authenticated');

      const comment = await Comment.findById(id);
      if (!comment) throw new UserInputError('Comment not found');

      if (comment.author.toString() !== user.id) {
        throw new AuthenticationError('Not authorized');
      }

      await Comment.deleteOne({ _id: id });
      return true;
    }
  },

  Subscription: {
    postCreated: {
      subscribe: () => pubsub.asyncIterator(['POST_CREATED'])
    },

    commentCreated: {
      subscribe: (_, { postId }) =>
        pubsub.asyncIterator([`COMMENT_CREATED_${postId}`])
    }
  },

  User: {
    posts: async (parent) => {
      return Post.find({ author: parent.id });
    }
  },

  Post: {
    author: async (parent) => {
      return User.findById(parent.author);
    },
    comments: async (parent) => {
      return Comment.find({ post: parent.id });
    }
  },

  Comment: {
    author: async (parent) => {
      return User.findById(parent.author);
    },
    post: async (parent) => {
      return Post.findById(parent.post);
    }
  }
};

export default resolvers;

Authentication Middleware 🔒

// src/middleware/auth.ts
import { GraphQLError } from 'graphql';
import { verify } from 'jsonwebtoken';
import { User } from '../models';

export interface Context {
  user?: any;
  token?: string;
}

export async function authenticate(
  { req }
): Promise<Context> {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) {
    return {};
  }

  try {
    const decoded = verify(token, process.env.JWT_SECRET!);
    const user = await User.findById(decoded.id);

    return { user, token };
  } catch (error) {
    throw new GraphQLError('Invalid token', {
      extensions: {
        code: 'UNAUTHENTICATED'
      }
    });
  }
}

Database Models 📊

// src/models/User.ts
import { Schema, model, Document } from 'mongoose';

export interface IUser extends Document {
  email: string;
  password: string;
  name: string;
  createdAt: Date;
  updatedAt: Date;
}

const userSchema = new Schema<IUser>(
  {
    email: {
      type: String,
      required: true,
      unique: true,
      lowercase: true
    },
    password: {
      type: String,
      required: true
    },
    name: {
      type: String,
      required: true
    }
  },
  { timestamps: true }
);

export const User = model<IUser>('User', userSchema);

// src/models/Post.ts
import { Schema, model, Document } from 'mongoose';

export interface IPost extends Document {
  title: string;
  content: string;
  author: Schema.Types.ObjectId;
  createdAt: Date;
  updatedAt: Date;
}

const postSchema = new Schema<IPost>(
  {
    title: {
      type: String,
      required: true
    },
    content: {
      type: String,
      required: true
    },
    author: {
      type: Schema.Types.ObjectId,
      ref: 'User',
      required: true
    }
  },
  { timestamps: true }
);

export const Post = model<IPost>('Post', postSchema);

Server Setup 🚀

// src/index.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { json } from 'body-parser';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
import mongoose from 'mongoose';

import typeDefs from './schema/typeDefs';
import resolvers from './resolvers';
import { authenticate } from './middleware/auth';

async function startServer() {
  const app = express();
  const httpServer = http.createServer(app);

  const schema = makeExecutableSchema({ typeDefs, resolvers });

  // WebSocket server for subscriptions
  const wsServer = new WebSocketServer({
    server: httpServer,
    path: '/graphql'
  });

  const serverCleanup = useServer({ schema }, wsServer);

  const server = new ApolloServer({
    schema,
    plugins: [
      ApolloServerPluginDrainHttpServer({ httpServer }),
      {
        async serverWillStart() {
          return {
            async drainServer() {
              await serverCleanup.dispose();
            }
          };
        }
      }
    ]
  });

  await server.start();

  app.use(
    '/graphql',
    cors<cors.CorsRequest>(),
    json(),
    expressMiddleware(server, {
      context: authenticate
    })
  );

  await mongoose.connect(process.env.MONGODB_URI!);

  const PORT = process.env.PORT || 4000;
  httpServer.listen(PORT, () => {
    console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`);
  });
}

startServer().catch(console.error);

Error Handling 🚨

// src/utils/errors.ts
import { GraphQLError } from 'graphql';

export class ValidationError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: {
        code: 'BAD_USER_INPUT'
      }
    });
  }
}

export class AuthenticationError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: {
        code: 'UNAUTHENTICATED'
      }
    });
  }
}

export class ForbiddenError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: {
        code: 'FORBIDDEN'
      }
    });
  }
}

// Usage in resolvers
import { ValidationError } from '../utils/errors';

throw new ValidationError('Invalid input');

Performance Optimization 🚀

// src/utils/dataloader.ts
import DataLoader from 'dataloader';
import { User, Post, Comment } from '../models';

export function createLoaders() {
  return {
    user: new DataLoader(async (userIds) => {
      const users = await User.find({ _id: { $in: userIds } });
      return userIds.map(id =>
        users.find(user => user.id === id)
      );
    }),

    post: new DataLoader(async (postIds) => {
      const posts = await Post.find({ _id: { $in: postIds } });
      return postIds.map(id =>
        posts.find(post => post.id === id)
      );
    }),

    comment: new DataLoader(async (commentIds) => {
      const comments = await Comment.find({ _id: { $in: commentIds } });
      return commentIds.map(id =>
        comments.find(comment => comment.id === id)
      );
    })
  };
}

// Usage in resolvers
const resolvers = {
  Post: {
    author: async (parent, _, { loaders }) => {
      return loaders.user.load(parent.author);
    }
  }
};

Best Practices 📝

  1. Use TypeScript for type safety
  2. Implement proper error handling
  3. Use DataLoader for N+1 queries
  4. Implement proper authentication
  5. Use proper validation
  6. Optimize performance
  7. Implement proper testing
  8. Use proper documentation
  9. Follow security best practices
  10. Monitor and log properly

Additional Resources

Building GraphQL APIs requires careful consideration of schema design, performance, and security. This guide provides a solid foundation for creating production-ready GraphQL APIs with Node.js and Apollo Server.