Implement GraphQL API endpoints
Builds a production-ready GraphQL API with authentication, real-time subscriptions, and comprehensive resolvers.
/plugin marketplace add davepoon/buildwithclaude/plugin install all-commands@buildwithclaudeImplement GraphQL API endpoints
GraphQL Setup and Configuration
Schema Definition and Type System
Schema Definition (SDL):
# schema/schema.graphql
# Scalar types
scalar DateTime
scalar EmailAddress
scalar PhoneNumber
scalar JSON
scalar Upload
# User types and enums
enum UserRole {
USER
ADMIN
MANAGER
}
enum UserStatus {
ACTIVE
INACTIVE
SUSPENDED
PENDING_VERIFICATION
}
type User {
id: ID!
email: EmailAddress!
username: String!
firstName: String!
lastName: String!
fullName: String!
phone: PhoneNumber
dateOfBirth: DateTime
avatar: String
role: UserRole!
status: UserStatus!
emailVerified: Boolean!
phoneVerified: Boolean!
profile: UserProfile
orders(
first: Int = 10
after: String
status: OrderStatus
): OrderConnection!
createdAt: DateTime!
updatedAt: DateTime!
lastLoginAt: DateTime
}
type UserProfile {
bio: String
website: String
location: String
timezone: String!
language: String!
notificationPreferences: JSON!
privacySettings: JSON!
}
# Product types
enum ProductStatus {
DRAFT
ACTIVE
INACTIVE
ARCHIVED
}
enum ProductVisibility {
VISIBLE
HIDDEN
CATALOG_ONLY
SEARCH_ONLY
}
type Product {
id: ID!
name: String!
slug: String!
sku: String!
description: String
shortDescription: String
price: Float!
comparePrice: Float
costPrice: Float
weight: Float
dimensions: ProductDimensions
category: Category
brand: Brand
vendor: Vendor
status: ProductStatus!
visibility: ProductVisibility!
inventoryTracking: Boolean!
inventoryQuantity: Int
lowStockThreshold: Int
allowBackorder: Boolean!
requiresShipping: Boolean!
isDigital: Boolean!
featured: Boolean!
tags: [String!]!
attributes: JSON!
images: [ProductImage!]!
variants: [ProductVariant!]!
reviews(
first: Int = 10
after: String
rating: Int
): ReviewConnection!
averageRating: Float
reviewCount: Int!
createdAt: DateTime!
updatedAt: DateTime!
publishedAt: DateTime
}
type ProductDimensions {
length: Float
width: Float
height: Float
unit: String!
}
type ProductImage {
id: ID!
url: String!
altText: String
sortOrder: Int!
}
type ProductVariant {
id: ID!
sku: String!
price: Float!
comparePrice: Float
inventoryQuantity: Int
attributes: JSON!
image: ProductImage
}
# Order types
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
REFUNDED
ON_HOLD
}
type Order {
id: ID!
orderNumber: String!
user: User
status: OrderStatus!
currency: String!
subtotal: Float!
taxTotal: Float!
shippingTotal: Float!
discountTotal: Float!
total: Float!
billingAddress: Address!
shippingAddress: Address!
shippingMethod: String
trackingNumber: String
items: [OrderItem!]!
notes: String
createdAt: DateTime!
updatedAt: DateTime!
shippedAt: DateTime
deliveredAt: DateTime
}
type OrderItem {
id: ID!
product: Product!
productVariant: ProductVariant
quantity: Int!
unitPrice: Float!
totalPrice: Float!
productName: String!
productSku: String!
productAttributes: JSON
}
type Address {
firstName: String!
lastName: String!
company: String
addressLine1: String!
addressLine2: String
city: String!
state: String
postalCode: String!
country: String!
phone: PhoneNumber
}
# Connection types for pagination
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProductEdge {
node: Product!
cursor: String!
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type OrderEdge {
node: Order!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Input types
input CreateUserInput {
email: EmailAddress!
password: String!
firstName: String!
lastName: String!
phone: PhoneNumber
dateOfBirth: DateTime
role: UserRole = USER
}
input UpdateUserInput {
email: EmailAddress
firstName: String
lastName: String
phone: PhoneNumber
dateOfBirth: DateTime
status: UserStatus
}
input ProductFilters {
category: ID
brand: ID
priceMin: Float
priceMax: Float
status: ProductStatus
featured: Boolean
inStock: Boolean
tags: [String!]
search: String
}
input CreateProductInput {
name: String!
slug: String!
sku: String!
description: String
price: Float!
comparePrice: Float
categoryId: ID
brandId: ID
status: ProductStatus = DRAFT
inventoryQuantity: Int = 0
attributes: JSON
tags: [String!]
}
# Root types
type Query {
# User queries
me: User
user(id: ID!): User
users(
first: Int = 10
after: String
search: String
role: UserRole
status: UserStatus
): UserConnection!
# Product queries
product(id: ID, slug: String): Product
products(
first: Int = 10
after: String
filters: ProductFilters
sortBy: ProductSortBy = CREATED_AT
sortOrder: SortOrder = DESC
): ProductConnection!
# Order queries
order(id: ID!): Order
orders(
first: Int = 10
after: String
status: OrderStatus
userId: ID
): OrderConnection!
# Search
search(
query: String!
first: Int = 10
after: String
types: [SearchType!] = [USER, PRODUCT, ORDER]
): SearchConnection!
}
type Mutation {
# Auth mutations
login(email: EmailAddress!, password: String!): AuthPayload!
logout: Boolean!
refreshToken: AuthPayload!
forgotPassword(email: EmailAddress!): Boolean!
resetPassword(token: String!, password: String!): AuthPayload!
# User mutations
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
updateProfile(input: UpdateProfileInput!): UserProfile!
# Product mutations
createProduct(input: CreateProductInput!): Product!
updateProduct(id: ID!, input: UpdateProductInput!): Product!
deleteProduct(id: ID!): Boolean!
uploadProductImage(productId: ID!, file: Upload!): ProductImage!
# Order mutations
createOrder(input: CreateOrderInput!): Order!
updateOrderStatus(id: ID!, status: OrderStatus!): Order!
addOrderItem(orderId: ID!, input: AddOrderItemInput!): OrderItem!
removeOrderItem(id: ID!): Boolean!
}
type Subscription {
# Real-time updates
orderUpdated(userId: ID): Order!
productUpdated(productId: ID): Product!
userStatusChanged(userId: ID): User!
# Admin subscriptions
newOrder: Order!
lowStockAlert: Product!
}
enum ProductSortBy {
CREATED_AT
NAME
PRICE
RATING
POPULARITY
}
enum SortOrder {
ASC
DESC
}
enum SearchType {
USER
PRODUCT
ORDER
}
type AuthPayload {
token: String!
refreshToken: String!
user: User!
expiresAt: DateTime!
}
Resolver Implementation
Main Resolvers:
// resolvers/index.js
const { GraphQLDateTime } = require('graphql-iso-date');
const { GraphQLEmailAddress, GraphQLPhoneNumber } = require('graphql-scalars');
const GraphQLJSON = require('graphql-type-json');
const GraphQLUpload = require('graphql-upload/GraphQLUpload.js');
const userResolvers = require('./userResolvers');
const productResolvers = require('./productResolvers');
const orderResolvers = require('./orderResolvers');
const searchResolvers = require('./searchResolvers');
const resolvers = {
// Custom scalars
DateTime: GraphQLDateTime,
EmailAddress: GraphQLEmailAddress,
PhoneNumber: GraphQLPhoneNumber,
JSON: GraphQLJSON,
Upload: GraphQLUpload,
// Root resolvers
Query: {
...userResolvers.Query,
...productResolvers.Query,
...orderResolvers.Query,
...searchResolvers.Query
},
Mutation: {
...userResolvers.Mutation,
...productResolvers.Mutation,
...orderResolvers.Mutation
},
Subscription: {
...userResolvers.Subscription,
...productResolvers.Subscription,
...orderResolvers.Subscription
},
// Type resolvers
User: userResolvers.User,
Product: productResolvers.Product,
Order: orderResolvers.Order
};
module.exports = resolvers;
User Resolvers:
// resolvers/userResolvers.js
const { AuthenticationError, ForbiddenError, UserInputError } = require('apollo-server-express');
const { withFilter } = require('graphql-subscriptions');
const userService = require('../services/userService');
const { requireAuth, requireRole } = require('../utils/authHelpers');
const { createConnectionFromArray } = require('../utils/connectionHelpers');
const userResolvers = {
Query: {
async me(parent, args, context) {
requireAuth(context);
return await userService.findById(context.user.id);
},
async user(parent, { id }, context) {
requireAuth(context);
const user = await userService.findById(id);
if (!user) {
throw new UserInputError('User not found');
}
// Privacy check - users can only see their own data unless admin
if (context.user.id !== user.id && !['admin', 'manager'].includes(context.user.role)) {
throw new ForbiddenError('Insufficient permissions');
}
return user;
},
async users(parent, { first, after, search, role, status }, context) {
requireAuth(context);
requireRole(context, ['admin', 'manager']);
const result = await userService.findUsers({
first,
after,
search,
role,
status
});
return createConnectionFromArray(result.users, {
first,
after,
totalCount: result.totalCount
});
}
},
Mutation: {
async createUser(parent, { input }, context) {
requireAuth(context);
requireRole(context, ['admin']);
// Check for existing user
const existingUser = await userService.findByEmail(input.email);
if (existingUser) {
throw new UserInputError('User with this email already exists');
}
const user = await userService.createUser(input);
// Publish subscription for real-time updates
context.pubsub.publish('USER_CREATED', { userCreated: user });
return user;
},
async updateUser(parent, { id, input }, context) {
requireAuth(context);
const existingUser = await userService.findById(id);
if (!existingUser) {
throw new UserInputError('User not found');
}
// Authorization check
if (context.user.id !== id && !['admin', 'manager'].includes(context.user.role)) {
throw new ForbiddenError('Insufficient permissions');
}
// Role change restriction
if (input.role && !['admin'].includes(context.user.role)) {
throw new ForbiddenError('Insufficient permissions to change user role');
}
const updatedUser = await userService.updateUser(id, input);
// Publish subscription
context.pubsub.publish('USER_UPDATED', { userUpdated: updatedUser });
return updatedUser;
},
async deleteUser(parent, { id }, context) {
requireAuth(context);
requireRole(context, ['admin']);
// Prevent self-deletion
if (context.user.id === id) {
throw new UserInputError('Cannot delete your own account');
}
const existingUser = await userService.findById(id);
if (!existingUser) {
throw new UserInputError('User not found');
}
await userService.deleteUser(id);
// Publish subscription
context.pubsub.publish('USER_DELETED', { userDeleted: existingUser });
return true;
}
},
Subscription: {
userStatusChanged: {
subscribe: withFilter(
(parent, args, context) => {
requireAuth(context);
return context.pubsub.asyncIterator(['USER_UPDATED']);
},
(payload, variables) => {
// Filter by userId if provided
return !variables.userId || payload.userUpdated.id === variables.userId;
}
)
}
},
// Field resolvers
User: {
fullName(parent) {
return `${parent.firstName} ${parent.lastName}`;
},
async profile(parent, args, context) {
return await userService.getUserProfile(parent.id);
},
async orders(parent, { first, after, status }, context) {
requireAuth(context);
// Users can only see their own orders unless admin
if (context.user.id !== parent.id && !['admin', 'manager'].includes(context.user.role)) {
throw new ForbiddenError('Insufficient permissions');
}
const result = await userService.getUserOrders(parent.id, {
first,
after,
status
});
return createConnectionFromArray(result.orders, {
first,
after,
totalCount: result.totalCount
});
}
}
};
module.exports = userResolvers;
DataLoader for N+1 Problem
DataLoader Implementation:
// dataLoaders/index.js
const DataLoader = require('dataloader');
const userService = require('../services/userService');
const productService = require('../services/productService');
const orderService = require('../services/orderService');
class DataLoaders {
constructor() {
this.userLoader = new DataLoader(
async (userIds) => {
const users = await userService.findByIds(userIds);
return userIds.map(id => users.find(user => user.id === id) || null);
},
{
cacheKeyFn: (key) => key.toString(),
maxBatchSize: 100
}
);
this.userProfileLoader = new DataLoader(
async (userIds) => {
const profiles = await userService.getProfilesByUserIds(userIds);
return userIds.map(id => profiles.find(profile => profile.userId === id) || null);
}
);
this.productLoader = new DataLoader(
async (productIds) => {
const products = await productService.findByIds(productIds);
return productIds.map(id => products.find(product => product.id === id) || null);
}
);
this.productCategoryLoader = new DataLoader(
async (categoryIds) => {
const categories = await productService.getCategoriesByIds(categoryIds);
return categoryIds.map(id => categories.find(category => category.id === id) || null);
}
);
this.productImagesLoader = new DataLoader(
async (productIds) => {
const imagesMap = await productService.getImagesByProductIds(productIds);
return productIds.map(id => imagesMap[id] || []);
}
);
this.orderItemsLoader = new DataLoader(
async (orderIds) => {
const itemsMap = await orderService.getItemsByOrderIds(orderIds);
return orderIds.map(id => itemsMap[id] || []);
}
);
this.productReviewsLoader = new DataLoader(
async (productIds) => {
const reviewsMap = await productService.getReviewsByProductIds(productIds);
return productIds.map(id => reviewsMap[id] || []);
}
);
}
// Clear all caches
clearAll() {
this.userLoader.clearAll();
this.userProfileLoader.clearAll();
this.productLoader.clearAll();
this.productCategoryLoader.clearAll();
this.productImagesLoader.clearAll();
this.orderItemsLoader.clearAll();
this.productReviewsLoader.clearAll();
}
// Clear specific cache
clearUser(userId) {
this.userLoader.clear(userId);
this.userProfileLoader.clear(userId);
}
clearProduct(productId) {
this.productLoader.clear(productId);
this.productImagesLoader.clear(productId);
this.productReviewsLoader.clear(productId);
}
}
module.exports = DataLoaders;
Authentication and Authorization
Auth Helpers:
// utils/authHelpers.js
const { AuthenticationError, ForbiddenError } = require('apollo-server-express');
const jwt = require('jsonwebtoken');
const userService = require('../services/userService');
class GraphQLAuth {
static async getUser(req) {
const authHeader = req.headers.authorization;
if (!authHeader) {
return null;
}
const token = authHeader.replace('Bearer ', '');
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await userService.findById(decoded.userId);
if (!user || user.status !== 'active') {
return null;
}
return user;
} catch (error) {
return null;
}
}
static requireAuth(context) {
if (!context.user) {
throw new AuthenticationError('Authentication required');
}
return context.user;
}
static requireRole(context, roles) {
this.requireAuth(context);
if (!roles.includes(context.user.role)) {
throw new ForbiddenError(`Requires one of the following roles: ${roles.join(', ')}`);
}
return context.user;
}
static requirePermission(context, permissions) {
this.requireAuth(context);
const userPermissions = context.user.permissions || [];
const hasPermission = permissions.some(permission =>
userPermissions.includes(permission)
);
if (!hasPermission) {
throw new ForbiddenError(`Requires one of the following permissions: ${permissions.join(', ')}`);
}
return context.user;
}
static canAccessResource(context, resourceUserId, adminRoles = ['admin', 'manager']) {
this.requireAuth(context);
const isOwner = context.user.id === resourceUserId;
const isAdmin = adminRoles.includes(context.user.role);
if (!isOwner && !isAdmin) {
throw new ForbiddenError('Insufficient permissions to access this resource');
}
return context.user;
}
}
// Export individual functions for convenience
const { requireAuth, requireRole, requirePermission, canAccessResource } = GraphQLAuth;
module.exports = {
GraphQLAuth,
requireAuth,
requireRole,
requirePermission,
canAccessResource
};
Real-time Subscriptions
Subscription Setup:
// subscriptions/index.js
const { PubSub } = require('graphql-subscriptions');
const { RedisPubSub } = require('graphql-redis-subscriptions');
const Redis = require('ioredis');
// Use Redis for production, in-memory for development
const createPubSub = () => {
if (process.env.NODE_ENV === 'production') {
const redisClient = new Redis(process.env.REDIS_URL);
return new RedisPubSub({
publisher: redisClient,
subscriber: redisClient.duplicate()
});
} else {
return new PubSub();
}
};
const pubsub = createPubSub();
// Subscription events
const SUBSCRIPTION_EVENTS = {
USER_CREATED: 'USER_CREATED',
USER_UPDATED: 'USER_UPDATED',
USER_DELETED: 'USER_DELETED',
ORDER_CREATED: 'ORDER_CREATED',
ORDER_UPDATED: 'ORDER_UPDATED',
PRODUCT_UPDATED: 'PRODUCT_UPDATED',
LOW_STOCK_ALERT: 'LOW_STOCK_ALERT'
};
// Subscription resolvers
const subscriptionResolvers = {
orderUpdated: {
subscribe: (parent, { userId }, context) => {
requireAuth(context);
// Users can only subscribe to their own orders unless admin
if (userId && context.user.id !== userId && !['admin', 'manager'].includes(context.user.role)) {
throw new ForbiddenError('Insufficient permissions');
}
return pubsub.asyncIterator([SUBSCRIPTION_EVENTS.ORDER_UPDATED]);
},
resolve: (payload, { userId }) => {
// Filter by userId if provided
if (userId && payload.orderUpdated.userId !== userId) {
return null;
}
return payload.orderUpdated;
}
},
productUpdated: {
subscribe: (parent, { productId }, context) => {
return pubsub.asyncIterator([SUBSCRIPTION_EVENTS.PRODUCT_UPDATED]);
},
resolve: (payload, { productId }) => {
// Filter by productId if provided
if (productId && payload.productUpdated.id !== productId) {
return null;
}
return payload.productUpdated;
}
},
userStatusChanged: {
subscribe: (parent, { userId }, context) => {
requireAuth(context);
requireRole(context, ['admin', 'manager']);
return pubsub.asyncIterator([SUBSCRIPTION_EVENTS.USER_UPDATED]);
},
resolve: (payload, { userId }) => {
if (userId && payload.userUpdated.id !== userId) {
return null;
}
return payload.userUpdated;
}
},
newOrder: {
subscribe: (parent, args, context) => {
requireAuth(context);
requireRole(context, ['admin', 'manager']);
return pubsub.asyncIterator([SUBSCRIPTION_EVENTS.ORDER_CREATED]);
}
},
lowStockAlert: {
subscribe: (parent, args, context) => {
requireAuth(context);
requireRole(context, ['admin', 'manager']);
return pubsub.asyncIterator([SUBSCRIPTION_EVENTS.LOW_STOCK_ALERT]);
}
}
};
module.exports = {
pubsub,
SUBSCRIPTION_EVENTS,
subscriptionResolvers
};
Error Handling and Validation
Error Handling:
// utils/errorHandling.js
const {
ApolloError,
AuthenticationError,
ForbiddenError,
UserInputError
} = require('apollo-server-express');
class GraphQLErrorHandler {
static handleError(error, operation) {
// Log error for debugging
console.error('GraphQL Error:', {
message: error.message,
operation: operation?.operationName,
variables: operation?.variables,
stack: error.stack
});
// Database errors
if (error.code === '23505') { // Unique constraint violation
return new UserInputError('A record with this information already exists');
}
if (error.code === '23503') { // Foreign key constraint violation
return new UserInputError('Referenced record does not exist');
}
// Validation errors
if (error.name === 'ValidationError') {
const messages = Object.values(error.errors).map(err => err.message);
return new UserInputError('Validation failed', {
validationErrors: messages
});
}
// Permission errors
if (error.message.includes('permission') || error.message.includes('access')) {
return new ForbiddenError(error.message);
}
// Authentication errors
if (error.message.includes('token') || error.message.includes('auth')) {
return new AuthenticationError(error.message);
}
// Network/external service errors
if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
return new ApolloError('External service unavailable', 'SERVICE_UNAVAILABLE');
}
// Default to internal error
return new ApolloError(
'An unexpected error occurred',
'INTERNAL_ERROR',
{ originalError: error.message }
);
}
static formatError(error) {
// Don't expose internal errors in production
if (process.env.NODE_ENV === 'production' && !error.extensions?.code) {
return new ApolloError('Internal server error', 'INTERNAL_ERROR');
}
// Add request ID for tracking
if (error.extensions?.requestId) {
error.extensions.requestId = error.extensions.requestId;
}
return error;
}
}
// Input validation helper
class InputValidator {
static validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new UserInputError('Invalid email format');
}
}
static validatePassword(password) {
if (password.length < 8) {
throw new UserInputError('Password must be at least 8 characters long');
}
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) {
throw new UserInputError('Password must contain uppercase, lowercase, and numeric characters');
}
}
static validatePhoneNumber(phone) {
const phoneRegex = /^\+?[\d\s\-\(\)]{10,20}$/;
if (!phoneRegex.test(phone)) {
throw new UserInputError('Invalid phone number format');
}
}
static validateRequired(value, fieldName) {
if (!value || (typeof value === 'string' && !value.trim())) {
throw new UserInputError(`${fieldName} is required`);
}
}
static validateStringLength(value, fieldName, min = 0, max = 255) {
if (typeof value !== 'string') {
throw new UserInputError(`${fieldName} must be a string`);
}
if (value.length < min) {
throw new UserInputError(`${fieldName} must be at least ${min} characters`);
}
if (value.length > max) {
throw new UserInputError(`${fieldName} must not exceed ${max} characters`);
}
}
static validateNumericRange(value, fieldName, min, max) {
if (typeof value !== 'number' || isNaN(value)) {
throw new UserInputError(`${fieldName} must be a valid number`);
}
if (min !== undefined && value < min) {
throw new UserInputError(`${fieldName} must be at least ${min}`);
}
if (max !== undefined && value > max) {
throw new UserInputError(`${fieldName} must not exceed ${max}`);
}
}
}
module.exports = {
GraphQLErrorHandler,
InputValidator
};
Performance Optimization
Query Complexity and Depth Limiting:
// utils/queryLimiting.js
const depthLimit = require('graphql-depth-limit');
const costAnalysis = require('graphql-query-complexity');
class QueryLimiting {
static createDepthLimit(maxDepth = 10) {
return depthLimit(maxDepth, {
ignoreIntrospection: true
});
}
static createComplexityAnalysis(maxComplexity = 1000) {
return costAnalysis({
maximumComplexity: maxComplexity,
introspection: true,
scalarCost: 1,
objectCost: 1,
listFactor: 10,
fieldExtensions: {
complexity: (options) => {
// Custom complexity calculation
const { args, childComplexity } = options;
// List fields have higher complexity
if (args.first) {
return childComplexity * Math.min(args.first, 100);
}
return childComplexity;
}
},
createError: (max, actual) => {
return new Error(`Query complexity ${actual} exceeds maximum allowed complexity ${max}`);
}
});
}
static createQueryTimeout(timeout = 30000) {
return {
willSendResponse(requestContext) {
if (requestContext.request.query) {
setTimeout(() => {
if (!requestContext.response.http.body) {
throw new Error('Query timeout exceeded');
}
}, timeout);
}
}
};
}
}
// Query caching
class QueryCache {
constructor(ttl = 300) { // 5 minutes default
this.cache = new Map();
this.ttl = ttl * 1000; // Convert to milliseconds
}
get(query, variables) {
const key = this.generateKey(query, variables);
const cached = this.cache.get(key);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.result;
}
this.cache.delete(key);
return null;
}
set(query, variables, result) {
const key = this.generateKey(query, variables);
this.cache.set(key, {
result,
timestamp: Date.now()
});
}
generateKey(query, variables) {
return `${query}:${JSON.stringify(variables || {})}`;
}
clear() {
this.cache.clear();
}
// Middleware for Apollo Server
static createCachePlugin(ttl = 300) {
const cache = new QueryCache(ttl);
return {
requestDidStart() {
return {
willSendResponse(requestContext) {
const { request, response } = requestContext;
// Only cache successful queries
if (response.http.body && !response.errors) {
cache.set(request.query, request.variables, response.http.body);
}
},
willSendRequest(requestContext) {
const { request } = requestContext;
const cached = cache.get(request.query, request.variables);
if (cached) {
requestContext.response.http.body = cached;
return;
}
}
};
}
};
}
}
module.exports = {
QueryLimiting,
QueryCache
};
GraphQL Testing
GraphQL Test Suite:
// tests/graphql/users.test.js
const { createTestClient } = require('apollo-server-testing');
const { gql } = require('apollo-server-express');
const { createTestServer } = require('../helpers/testServer');
const { createTestUser, getAuthToken } = require('../helpers/testHelpers');
describe('User GraphQL API', () => {
let server, query, mutate;
let testUser, authToken;
beforeAll(async () => {
server = await createTestServer();
const testClient = createTestClient(server);
query = testClient.query;
mutate = testClient.mutate;
testUser = await createTestUser({ role: 'admin' });
authToken = await getAuthToken(testUser);
});
describe('Queries', () => {
const GET_USERS = gql`
query GetUsers($first: Int, $search: String) {
users(first: $first, search: $search) {
edges {
node {
id
email
firstName
lastName
role
status
createdAt
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
`;
test('should return paginated users list', async () => {
const result = await query({
query: GET_USERS,
variables: { first: 10 },
context: { user: testUser }
});
expect(result.errors).toBeUndefined();
expect(result.data.users).toMatchObject({
edges: expect.any(Array),
pageInfo: {
hasNextPage: expect.any(Boolean),
hasPreviousPage: expect.any(Boolean)
},
totalCount: expect.any(Number)
});
if (result.data.users.edges.length > 0) {
expect(result.data.users.edges[0].node).toHaveProperty('id');
expect(result.data.users.edges[0].node).toHaveProperty('email');
expect(result.data.users.edges[0].node).not.toHaveProperty('password');
}
});
test('should filter users by search term', async () => {
const result = await query({
query: GET_USERS,
variables: { search: 'test' },
context: { user: testUser }
});
expect(result.errors).toBeUndefined();
expect(result.data.users.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({
node: expect.objectContaining({
email: expect.stringContaining('test')
})
})
])
);
});
test('should require authentication', async () => {
const result = await query({
query: GET_USERS,
variables: { first: 10 }
});
expect(result.errors).toBeDefined();
expect(result.errors[0].extensions.code).toBe('UNAUTHENTICATED');
});
const GET_ME = gql`
query GetMe {
me {
id
email
firstName
lastName
profile {
bio
website
}
}
}
`;
test('should return current user profile', async () => {
const result = await query({
query: GET_ME,
context: { user: testUser }
});
expect(result.errors).toBeUndefined();
expect(result.data.me).toMatchObject({
id: testUser.id.toString(),
email: testUser.email,
firstName: testUser.firstName,
lastName: testUser.lastName
});
});
});
describe('Mutations', () => {
const CREATE_USER = gql`
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
email
firstName
lastName
role
status
}
}
`;
test('should create user with valid input', async () => {
const userInput = {
email: 'newuser@example.com',
password: 'SecurePass123',
firstName: 'New',
lastName: 'User',
role: 'USER'
};
const result = await mutate({
mutation: CREATE_USER,
variables: { input: userInput },
context: { user: testUser }
});
expect(result.errors).toBeUndefined();
expect(result.data.createUser).toMatchObject({
email: userInput.email,
firstName: userInput.firstName,
lastName: userInput.lastName,
role: userInput.role,
status: 'ACTIVE'
});
expect(result.data.createUser).toHaveProperty('id');
});
test('should validate email format', async () => {
const userInput = {
email: 'invalid-email',
password: 'SecurePass123',
firstName: 'Test',
lastName: 'User'
};
const result = await mutate({
mutation: CREATE_USER,
variables: { input: userInput },
context: { user: testUser }
});
expect(result.errors).toBeDefined();
expect(result.errors[0].extensions.code).toBe('BAD_USER_INPUT');
});
test('should prevent duplicate email', async () => {
const userInput = {
email: testUser.email,
password: 'SecurePass123',
firstName: 'Test',
lastName: 'User'
};
const result = await mutate({
mutation: CREATE_USER,
variables: { input: userInput },
context: { user: testUser }
});
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toContain('already exists');
});
});
describe('Subscriptions', () => {
test('should subscribe to user status changes', (done) => {
const USER_STATUS_CHANGED = gql`
subscription UserStatusChanged($userId: ID) {
userStatusChanged(userId: $userId) {
id
status
}
}
`;
const observable = server.subscription({
query: USER_STATUS_CHANGED,
variables: { userId: testUser.id },
context: { user: testUser }
});
observable.subscribe({
next: (result) => {
expect(result.data.userStatusChanged).toMatchObject({
id: testUser.id.toString(),
status: expect.any(String)
});
done();
},
error: done
});
// Trigger the subscription by updating user status
setTimeout(() => {
server.pubsub.publish('USER_UPDATED', {
userUpdated: { ...testUser, status: 'INACTIVE' }
});
}, 100);
});
});
describe('Performance', () => {
test('should handle complex queries efficiently', async () => {
const COMPLEX_QUERY = gql`
query ComplexQuery {
users(first: 5) {
edges {
node {
id
email
profile {
bio
}
orders(first: 3) {
edges {
node {
id
total
items {
id
product {
id
name
}
}
}
}
}
}
}
}
}
`;
const start = Date.now();
const result = await query({
query: COMPLEX_QUERY,
context: { user: testUser }
});
const duration = Date.now() - start;
expect(result.errors).toBeUndefined();
expect(duration).toBeLessThan(2000); // Should complete within 2 seconds
});
test('should limit query depth', async () => {
const DEEP_QUERY = gql`
query DeepQuery {
users {
edges {
node {
orders {
edges {
node {
items {
product {
category {
parent {
parent {
parent {
name
}
}
}
}
}
}
}
}
}
}
}
}
}
`;
const result = await query({
query: DEEP_QUERY,
context: { user: testUser }
});
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toContain('depth');
});
});
});
Production Setup and Deployment
Production Configuration:
// server/apollo.js
const { ApolloServer } = require('apollo-server-express');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { shield, rule, and, or } = require('graphql-shield');
const depthLimit = require('graphql-depth-limit');
const costAnalysis = require('graphql-query-complexity');
const typeDefs = require('../schema');
const resolvers = require('../resolvers');
const { GraphQLAuth } = require('../utils/authHelpers');
const { GraphQLErrorHandler } = require('../utils/errorHandling');
const { QueryLimiting, QueryCache } = require('../utils/queryLimiting');
const DataLoaders = require('../dataLoaders');
const { pubsub } = require('../subscriptions');
// Security rules
const rules = {
isAuthenticated: rule({ cache: 'contextual' })(
async (parent, args, context) => {
return !!context.user;
}
),
isAdmin: rule({ cache: 'contextual' })(
async (parent, args, context) => {
return context.user && ['admin'].includes(context.user.role);
}
),
isManagerOrAdmin: rule({ cache: 'contextual' })(
async (parent, args, context) => {
return context.user && ['admin', 'manager'].includes(context.user.role);
}
)
};
const permissions = shield({
Query: {
me: rules.isAuthenticated,
user: rules.isAuthenticated,
users: rules.isManagerOrAdmin,
orders: rules.isManagerOrAdmin
},
Mutation: {
createUser: rules.isAdmin,
updateUser: rules.isAuthenticated,
deleteUser: rules.isAdmin,
createProduct: rules.isManagerOrAdmin,
updateProduct: rules.isManagerOrAdmin,
deleteProduct: rules.isAdmin
},
Subscription: {
userStatusChanged: rules.isManagerOrAdmin,
newOrder: rules.isManagerOrAdmin,
lowStockAlert: rules.isManagerOrAdmin
}
}, {
allowExternalErrors: true,
fallbackError: 'Not authorized for this operation'
});
const createApolloServer = () => {
const schema = makeExecutableSchema({
typeDefs,
resolvers
});
return new ApolloServer({
schema: permissions(schema),
context: async ({ req, connection }) => {
// WebSocket connection (subscriptions)
if (connection) {
return {
user: connection.context.user,
dataLoaders: new DataLoaders(),
pubsub
};
}
// HTTP request
const user = await GraphQLAuth.getUser(req);
return {
user,
dataLoaders: new DataLoaders(),
pubsub,
req
};
},
formatError: GraphQLErrorHandler.formatError,
validationRules: [
QueryLimiting.createDepthLimit(10),
QueryLimiting.createComplexityAnalysis(1000)
],
plugins: [
QueryCache.createCachePlugin(300), // 5 minutes cache
{
requestDidStart() {
return {
willSendResponse(requestContext) {
// Clear DataLoaders after each request
if (requestContext.context.dataLoaders) {
requestContext.context.dataLoaders.clearAll();
}
}
};
}
}
],
introspection: process.env.NODE_ENV !== 'production',
playground: process.env.NODE_ENV !== 'production',
subscriptions: {
onConnect: async (connectionParams, webSocket, context) => {
// Authenticate WebSocket connections
if (connectionParams.authorization) {
const user = await GraphQLAuth.getUser({
headers: { authorization: connectionParams.authorization }
});
return { user };
}
throw new Error('Missing auth token!');
},
onDisconnect: (webSocket, context) => {
console.log('Client disconnected');
}
}
});
};
module.exports = createApolloServer;