Recipes
Common patterns and solutions for building GraphQL APIs with Axolotl.
Authentication & Authorization
JWT Authentication
// src/utils/auth.ts
import jwt from 'jsonwebtoken';
import { GraphQLError } from 'graphql';
export function verifyToken(token: string): { userId: string; roles: string[] } | null {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;
return { userId: decoded.userId, roles: decoded.roles || [] };
} catch {
return null;
}
}
export function generateToken(userId: string, roles: string[]): string {
return jwt.sign({ userId, roles }, process.env.JWT_SECRET!, {
expiresIn: '7d',
});
}
// src/context.ts
import { YogaInitialContext } from 'graphql-yoga';
export async function buildContext(initial: YogaInitialContext) {
const authHeader = initial.request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
const auth = token ? verifyToken(token) : null;
return {
...initial,
userId: auth?.userId || null,
userRoles: auth?.roles || [],
};
}Login/Register Resolvers
import { createResolvers } from '@/src/axolotl.js';
import { GraphQLError } from 'graphql';
import bcrypt from 'bcrypt';
export default createResolvers({
Mutation: {
register: async ([, , ctx], { input }) => {
// Validate
if (input.password.length < 8) {
throw new GraphQLError('Password must be at least 8 characters');
}
// Check existing
const existing = await ctx.db.user.findUnique({
where: { email: input.email },
});
if (existing) {
throw new GraphQLError('Email already registered', {
extensions: { code: 'CONFLICT' },
});
}
// Hash password
const hashedPassword = await bcrypt.hash(input.password, 10);
// Create user
const user = await ctx.db.user.create({
data: {
email: input.email,
username: input.username,
password: hashedPassword,
},
});
// Generate token
const token = generateToken(user.id, ['USER']);
return { user, token };
},
login: async ([, , ctx], { email, password }) => {
const user = await ctx.db.user.findUnique({
where: { email },
});
if (!user) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHORIZED' },
});
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHORIZED' },
});
}
const token = generateToken(user.id, user.roles);
return { user, token };
},
},
});File Upload
// Schema
type Mutation {
uploadAvatar(file: Upload!): String!
}
// Resolver
import { createResolvers } from '@/src/axolotl.js';
import { writeFile } from 'fs/promises';
import path from 'path';
export default createResolvers({
Mutation: {
uploadAvatar: async ([, , ctx], { file }) => {
if (!ctx.userId) throw unauthorized();
const { createReadStream, filename, mimetype } = await file;
// Validate file type
if (!['image/jpeg', 'image/png'].includes(mimetype)) {
throw new GraphQLError('Only JPG and PNG files are allowed');
}
// Generate unique filename
const ext = path.extname(filename);
const newFilename = `${ctx.userId}-${Date.now()}${ext}`;
const filepath = path.join('uploads', newFilename);
// Save file
const stream = createReadStream();
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
await writeFile(filepath, Buffer.concat(chunks));
// Update user record
await ctx.db.user.update({
where: { id: ctx.userId },
data: { avatarUrl: `/uploads/${newFilename}` }
});
return `/uploads/${newFilename}`;
}
}
});Pagination
Cursor-Based Pagination
createResolvers({
Query: {
posts: async ([, , ctx], { after, limit = 10 }) => {
const posts = await ctx.db.post.findMany({
take: limit + 1, // Fetch one extra
...(after && {
cursor: { id: after },
skip: 1,
}),
orderBy: { createdAt: 'desc' },
});
const hasNextPage = posts.length > limit;
const edges = hasNextPage ? posts.slice(0, -1) : posts;
return {
edges: edges.map((post) => ({
node: post,
cursor: post.id,
})),
pageInfo: {
hasNextPage,
endCursor: edges[edges.length - 1]?.id || null,
},
};
},
},
});Offset-Based Pagination
createResolvers({
Query: {
users: async ([, , ctx], { offset = 0, limit = 10 }) => {
const [users, totalCount] = await Promise.all([
ctx.db.user.findMany({
skip: offset,
take: limit,
}),
ctx.db.user.count(),
]);
return {
edges: users,
pageInfo: {
totalCount,
hasNextPage: offset + limit < totalCount,
hasPreviousPage: offset > 0,
},
};
},
},
});Filtering & Sorting
createResolvers({
Query: {
posts: async ([, , ctx], { filter, sort }) => {
const where: any = {};
// Build filter
if (filter?.authorId) {
where.authorId = filter.authorId;
}
if (filter?.search) {
where.OR = [
{ title: { contains: filter.search, mode: 'insensitive' } },
{ content: { contains: filter.search, mode: 'insensitive' } },
];
}
if (filter?.status) {
where.status = filter.status;
}
// Build sort
const orderBy: any = {};
if (sort?.field === 'CREATED_AT') {
orderBy.createdAt = sort.direction === 'ASC' ? 'asc' : 'desc';
} else if (sort?.field === 'TITLE') {
orderBy.title = sort.direction === 'ASC' ? 'asc' : 'desc';
}
return ctx.db.post.findMany({ where, orderBy });
},
},
});Subscriptions (Real-time)
import { createPubSub } from 'graphql-yoga';
const pubsub = createPubSub();
export default createResolvers({
Mutation: {
createPost: async ([, , ctx], { input }) => {
const post = await ctx.db.post.create({ data: input });
// Publish event
pubsub.publish('POST_CREATED', { postCreated: post });
return post;
},
},
Subscription: {
postCreated: {
subscribe: () => pubsub.subscribe('POST_CREATED'),
},
},
});Background Jobs
import { Queue } from 'bullmq';
const emailQueue = new Queue('email', {
connection: { host: 'localhost', port: 6379 },
});
createResolvers({
Mutation: {
sendWelcomeEmail: async ([, , ctx], { userId }) => {
// Add job to queue
await emailQueue.add('welcome', {
userId,
template: 'welcome',
});
return true;
},
},
});
// Worker (separate process)
import { Worker } from 'bullmq';
const worker = new Worker(
'email',
async (job) => {
const { userId, template } = job.data;
// Send email
await sendEmail(userId, template);
},
{
connection: { host: 'localhost', port: 6379 },
},
);Soft Deletes
createResolvers({
Query: {
posts: async ([, , ctx], { includeDeleted = false }) => {
return ctx.db.post.findMany({
where: includeDeleted ? undefined : { deletedAt: null },
});
},
},
Mutation: {
deletePost: async ([, , ctx], { id }) => {
// Soft delete
await ctx.db.post.update({
where: { id },
data: { deletedAt: new Date() },
});
return true;
},
permanentlyDeletePost: async ([, , ctx], { id }) => {
// Hard delete
await ctx.db.post.delete({ where: { id } });
return true;
},
},
});Audit Logging
async function logAction(ctx: AppContext, action: string, details: any) {
await ctx.db.auditLog.create({
data: {
userId: ctx.userId,
action,
details: JSON.stringify(details),
ipAddress: ctx.request.headers.get('x-forwarded-for'),
userAgent: ctx.request.headers.get('user-agent'),
timestamp: new Date(),
},
});
}
createResolvers({
Mutation: {
updateUser: async ([, , ctx], { id, input }) => {
const user = await ctx.db.user.update({
where: { id },
data: input,
});
await logAction(ctx, 'UPDATE_USER', { userId: id, changes: input });
return user;
},
},
});Batch Operations
createResolvers({
Mutation: {
deletePosts: async ([, , ctx], { ids }) => {
const result = await ctx.db.post.deleteMany({
where: {
id: { in: ids },
authorId: ctx.userId, // Ensure ownership
},
});
return result.count;
},
updatePostsStatus: async ([, , ctx], { ids, status }) => {
await ctx.db.post.updateMany({
where: {
id: { in: ids },
authorId: ctx.userId,
},
data: { status },
});
return true;
},
},
});Webhooks
import fetch from 'node-fetch';
createResolvers({
Mutation: {
createPost: async ([, , ctx], { input }) => {
const post = await ctx.db.post.create({ data: input });
// Trigger webhooks
const webhooks = await ctx.db.webhook.findMany({
where: { event: 'POST_CREATED', active: true },
});
for (const webhook of webhooks) {
fetch(webhook.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'POST_CREATED',
data: post,
timestamp: new Date().toISOString(),
}),
}).catch((err) => console.error('Webhook failed:', err));
}
return post;
},
},
});Caching with Redis
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
createResolvers({
Query: {
popularPosts: async ([, , ctx]) => {
const cacheKey = 'popular-posts';
// Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Fetch from DB
const posts = await ctx.db.post.findMany({
take: 10,
orderBy: { views: 'desc' },
});
// Cache for 5 minutes
await redis.setex(cacheKey, 300, JSON.stringify(posts));
return posts;
},
},
});Search with Elasticsearch
import { Client } from '@elastic/elasticsearch';
const es = new Client({ node: process.env.ELASTICSEARCH_URL });
createResolvers({
Query: {
searchPosts: async ([, , ctx], { query, limit = 10 }) => {
const { body } = await es.search({
index: 'posts',
body: {
query: {
multi_match: {
query,
fields: ['title^2', 'content'],
},
},
size: limit,
},
});
const postIds = body.hits.hits.map((hit: any) => hit._id);
return ctx.db.post.findMany({
where: { id: { in: postIds } },
});
},
},
});Related Resources
- Best Practices for production patterns
- Data Loaders for performance
- Directives for cross-cutting concerns
- Tests for testing strategies