Implement GraphQL API
July 12, 2025 ยท View on GitHub
Implement GraphQL API endpoints
Instructions
-
GraphQL Setup and Configuration
- Set up GraphQL server with Apollo Server or similar
- Configure schema-first or code-first approach
- Plan GraphQL architecture and data modeling
- Set up development tools and introspection
- Configure GraphQL playground and documentation
-
Schema Definition and Type System
- Define comprehensive GraphQL schema:
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
- Implement comprehensive resolvers:
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
- Implement efficient data loading:
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
- Implement GraphQL-specific auth:
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
- Implement GraphQL 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
- Implement comprehensive error handling:
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
- Implement GraphQL performance optimizations:
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
- Implement comprehensive 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
- Configure GraphQL for production:
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;