GraphQL
Building a GraphQL API Project (Part 2)
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:
- Implement comment CRUD resolvers with nested replies
- Test cursor-based pagination with 100+ posts
- Upload avatar images and verify file storage
- Set up WebSocket client to test subscriptions
- 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!