GraphQL

Building a GraphQL API Project (Part 2)

18 min Lesson 33 of 35

Building a GraphQL API Project (Part 2)

In this lesson, we'll extend our blog platform API with advanced features including post/comment resolvers, cursor-based pagination, file uploads, real-time subscriptions, and role-based access control.

Post Resolvers with Advanced Features

// resolvers/postResolvers.js const { Post, User, Comment } = require('../models'); const { Op } = require('sequelize'); const slugify = require('slugify'); const postResolvers = { Query: { post: async (_, { id, slug }) => { const where = id ? { id } : { slug }; const post = await Post.findOne({ where, include: [ { model: User, as: 'author' }, { model: Comment, as: 'comments' }, ], }); if (post) { // Increment view count await post.increment('viewCount'); } return post; }, posts: async (_, { limit = 10, offset = 0, status, authorId }) => { const where = {}; if (status) where.status = status; if (authorId) where.authorId = authorId; return Post.findAll({ where, limit, offset, order: [['createdAt', 'DESC']], include: [{ model: User, as: 'author' }], }); }, searchPosts: async (_, { query }) => { return Post.findAll({ where: { [Op.or]: [ { title: { [Op.iLike]: `%${query}%` } }, { content: { [Op.iLike]: `%${query}%` } }, { tags: { [Op.contains]: [query] } }, ], status: 'PUBLISHED', }, include: [{ model: User, as: 'author' }], }); }, }, Mutation: { createPost: async (_, { input }, { user }) => { if (!user) throw new Error('Not authenticated'); const slug = slugify(input.title, { lower: true, strict: true }); const post = await Post.create({ ...input, slug, authorId: user.id, }); return Post.findByPk(post.id, { include: [{ model: User, as: 'author' }], }); }, updatePost: async (_, { id, input }, { user }) => { if (!user) throw new Error('Not authenticated'); const post = await Post.findByPk(id); if (!post) throw new Error('Post not found'); // Check ownership or admin role if (post.authorId !== user.id && user.role !== 'ADMIN') { throw new Error('Not authorized'); } if (input.title) { input.slug = slugify(input.title, { lower: true, strict: true }); } await post.update(input); return Post.findByPk(id, { include: [{ model: User, as: 'author' }], }); }, deletePost: async (_, { id }, { user }) => { if (!user) throw new Error('Not authenticated'); const post = await Post.findByPk(id); if (!post) throw new Error('Post not found'); if (post.authorId !== user.id && user.role !== 'ADMIN') { throw new Error('Not authorized'); } await post.destroy(); return true; }, publishPost: async (_, { id }, { user }) => { if (!user) throw new Error('Not authenticated'); const post = await Post.findByPk(id); if (!post) throw new Error('Post not found'); if (post.authorId !== user.id) { throw new Error('Not authorized'); } await post.update({ status: 'PUBLISHED', publishedAt: new Date(), }); return Post.findByPk(id, { include: [{ model: User, as: 'author' }], }); }, }, Post: { author: async (parent) => { return User.findByPk(parent.authorId); }, comments: async (parent) => { return Comment.findAll({ where: { postId: parent.id, parentId: null }, order: [['createdAt', 'DESC']], }); }, }, }; module.exports = postResolvers;

Cursor-Based Pagination

Implement efficient pagination for large datasets:

// schema/typeDefs.js - Add pagination types type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String } type PostEdge { cursor: String! node: Post! } type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! totalCount: Int! } extend type Query { postsPaginated( first: Int after: String status: PostStatus ): PostConnection! }
// resolvers/paginationResolvers.js const { Post, User } = require('../models'); const { Op } = require('sequelize'); const paginationResolvers = { Query: { postsPaginated: async (_, { first = 10, after, status }) => { const where = {}; if (status) where.status = status; // Decode cursor (base64 encoded ID) if (after) { const decodedCursor = Buffer.from(after, 'base64').toString('utf-8'); where.id = { [Op.lt]: decodedCursor }; } // Fetch one extra to determine if there's a next page const posts = await Post.findAll({ where, limit: first + 1, order: [['createdAt', 'DESC']], include: [{ model: User, as: 'author' }], }); const hasNextPage = posts.length > first; const nodes = hasNextPage ? posts.slice(0, -1) : posts; const edges = nodes.map(node => ({ cursor: Buffer.from(node.id).toString('base64'), node, })); const totalCount = await Post.count({ where }); return { edges, pageInfo: { hasNextPage, hasPreviousPage: !!after, startCursor: edges[0]?.cursor, endCursor: edges[edges.length - 1]?.cursor, }, totalCount, }; }, }, }; module.exports = paginationResolvers;
Cursor vs Offset: Cursor-based pagination is more efficient for large datasets and handles real-time data changes better than offset-based pagination. Use it for infinite scroll features.

File Upload Implementation

// Install dependency npm install graphql-upload // schema/typeDefs.js scalar Upload extend type Mutation { uploadAvatar(file: Upload!): String! uploadPostImage(file: Upload!): String! }
// resolvers/uploadResolvers.js const { GraphQLUpload } = require('graphql-upload'); const fs = require('fs'); const path = require('path'); const { v4: uuidv4 } = require('uuid'); const uploadResolvers = { Upload: GraphQLUpload, Mutation: { uploadAvatar: async (_, { file }, { user }) => { if (!user) throw new Error('Not authenticated'); const { createReadStream, filename, mimetype } = await file; // Validate file type if (!mimetype.startsWith('image/')) { throw new Error('Only image files are allowed'); } // Generate unique filename const fileExt = path.extname(filename); const uniqueFilename = `${uuidv4()}${fileExt}`; const uploadPath = path.join(__dirname, '../uploads/avatars', uniqueFilename); // Create write stream const stream = createReadStream(); await new Promise((resolve, reject) => stream .pipe(fs.createWriteStream(uploadPath)) .on('finish', resolve) .on('error', reject) ); const fileUrl = `/uploads/avatars/${uniqueFilename}`; // Update user avatar await user.update({ avatar: fileUrl }); return fileUrl; }, uploadPostImage: async (_, { file }, { user }) => { if (!user) throw new Error('Not authenticated'); const { createReadStream, filename, mimetype } = await file; if (!mimetype.startsWith('image/')) { throw new Error('Only image files are allowed'); } const fileExt = path.extname(filename); const uniqueFilename = `${uuidv4()}${fileExt}`; const uploadPath = path.join(__dirname, '../uploads/posts', uniqueFilename); const stream = createReadStream(); await new Promise((resolve, reject) => stream .pipe(fs.createWriteStream(uploadPath)) .on('finish', resolve) .on('error', reject) ); return `/uploads/posts/${uniqueFilename}`; }, }, }; module.exports = uploadResolvers;

Real-Time Subscriptions

// Install dependencies npm install graphql-subscriptions // schema/typeDefs.js type Subscription { postCreated: Post! commentAdded(postId: ID!): Comment! postUpdated(postId: ID!): Post! }
// pubsub.js - Pub/Sub instance const { PubSub } = require('graphql-subscriptions'); const pubsub = new PubSub(); const POST_CREATED = 'POST_CREATED'; const COMMENT_ADDED = 'COMMENT_ADDED'; const POST_UPDATED = 'POST_UPDATED'; module.exports = { pubsub, POST_CREATED, COMMENT_ADDED, POST_UPDATED };
// resolvers/subscriptionResolvers.js const { pubsub, POST_CREATED, COMMENT_ADDED, POST_UPDATED } = require('../pubsub'); const subscriptionResolvers = { Subscription: { postCreated: { subscribe: () => pubsub.asyncIterator([POST_CREATED]), }, commentAdded: { subscribe: (_, { postId }) => { return pubsub.asyncIterator([`${COMMENT_ADDED}_${postId}`]); }, }, postUpdated: { subscribe: (_, { postId }) => { return pubsub.asyncIterator([`${POST_UPDATED}_${postId}`]); }, }, }, }; // Trigger in mutations // After creating post: await pubsub.publish(POST_CREATED, { postCreated: post }); // After adding comment: await pubsub.publish(`${COMMENT_ADDED}_${postId}`, { commentAdded: comment }); module.exports = subscriptionResolvers;
Production Note: For production, use Redis-based PubSub (graphql-redis-subscriptions) for horizontal scaling across multiple server instances.

Role-Based Access Control

// utils/authorization.js const requireAuth = (user) => { if (!user) { throw new Error('Authentication required'); } }; const requireRole = (user, roles) => { requireAuth(user); if (!roles.includes(user.role)) { throw new Error(`Access denied. Required role: ${roles.join(' or ')}`); } }; const isOwnerOrAdmin = (user, ownerId) => { requireAuth(user); if (user.id !== ownerId && user.role !== 'ADMIN') { throw new Error('Access denied. You must be the owner or an admin'); } }; module.exports = { requireAuth, requireRole, isOwnerOrAdmin };
// Apply authorization in resolvers const { requireAuth, requireRole, isOwnerOrAdmin } = require('../utils/authorization'); createPost: async (_, { input }, { user }) => { requireRole(user, ['AUTHOR', 'ADMIN']); // Create post logic... }, deletePost: async (_, { id }, { user }) => { const post = await Post.findByPk(id); if (!post) throw new Error('Post not found'); isOwnerOrAdmin(user, post.authorId); // Delete logic... },

API Documentation with Schema Directives

// schema/typeDefs.js - Add descriptions """ Represents a blog post with content, metadata, and author information """ type Post { "Unique identifier" id: ID! "Post title (max 200 characters)" title: String! "URL-friendly slug for SEO" slug: String! "Full post content in Markdown" content: String! "Short excerpt (max 300 characters)" excerpt: String "Featured image URL" featuredImage: String "Publication status" status: PostStatus! "Post author" author: User! "Comments on this post" comments: [Comment!]! "Post tags for categorization" tags: [String!]! "Number of times post has been viewed" viewCount: Int! "Creation timestamp" createdAt: String! "Last update timestamp" updatedAt: String! "Publication date (null if draft)" publishedAt: String }
Practice Exercise:
  1. Implement comment CRUD resolvers with nested replies
  2. Test cursor-based pagination with 100+ posts
  3. Upload avatar images and verify file storage
  4. Set up WebSocket client to test subscriptions
  5. Test role-based access: USER cannot create posts, AUTHOR can
Complete API: Your blog platform API now supports full CRUD operations, file uploads, real-time updates, pagination, and secure role-based access control. Ready for production deployment!