Tutorial: API Development

Build a robust REST API with authentication, validation, rate limiting, and documentation.

What You'll Build#

  • RESTful CRUD endpoints
  • API key authentication
  • Request validation with Zod
  • Rate limiting
  • Error handling
  • OpenAPI documentation

Prerequisites#

  • Next.js project with App Router
  • Prisma configured
  • Bootspring initialized

Time Required#

Approximately 35 minutes.

Step 1: Design the API#

Ask the api-expert:

bootspring agent invoke api-expert "Design a REST API for a task management system with projects, tasks, and comments"

The agent provides:

  • Resource structure
  • Endpoint patterns
  • Authentication approach
  • Error handling strategy

Step 2: Apply API Skills#

bootspring skill apply api/rest-crud bootspring skill apply api/rate-limiting bootspring skill apply api/api-keys

Step 3: Create API Key Authentication#

Database Schema#

1// prisma/schema.prisma 2 3model ApiKey { 4 id String @id @default(cuid()) 5 key String @unique 6 name String 7 permissions String[] @default(["read"]) 8 lastUsedAt DateTime? 9 expiresAt DateTime? 10 11 userId String 12 user User @relation(fields: [userId], references: [id], onDelete: Cascade) 13 14 createdAt DateTime @default(now()) 15 updatedAt DateTime @updatedAt 16 17 @@index([key]) 18 @@index([userId]) 19}

API Key Utilities#

1// lib/api-key.ts 2import { prisma } from '@/lib/prisma'; 3import { createHash, randomBytes } from 'crypto'; 4 5export function generateApiKey(): string { 6 const prefix = 'bsk_'; 7 const key = randomBytes(32).toString('hex'); 8 return `${prefix}${key}`; 9} 10 11export function hashApiKey(key: string): string { 12 return createHash('sha256').update(key).digest('hex'); 13} 14 15export async function validateApiKey(key: string) { 16 const hashedKey = hashApiKey(key); 17 18 const apiKey = await prisma.apiKey.findUnique({ 19 where: { key: hashedKey }, 20 include: { user: true }, 21 }); 22 23 if (!apiKey) { 24 return null; 25 } 26 27 // Check expiration 28 if (apiKey.expiresAt && apiKey.expiresAt < new Date()) { 29 return null; 30 } 31 32 // Update last used 33 await prisma.apiKey.update({ 34 where: { id: apiKey.id }, 35 data: { lastUsedAt: new Date() }, 36 }); 37 38 return { 39 apiKey, 40 user: apiKey.user, 41 }; 42} 43 44export async function createApiKey( 45 userId: string, 46 name: string, 47 permissions: string[] = ['read'] 48) { 49 const key = generateApiKey(); 50 const hashedKey = hashApiKey(key); 51 52 await prisma.apiKey.create({ 53 data: { 54 key: hashedKey, 55 name, 56 permissions, 57 userId, 58 }, 59 }); 60 61 // Return the unhashed key (only time it's visible) 62 return key; 63}

API Authentication Middleware#

1// lib/api-auth.ts 2import { NextRequest, NextResponse } from 'next/server'; 3import { validateApiKey } from './api-key'; 4 5export type ApiContext = { 6 user: { 7 id: string; 8 email: string; 9 }; 10 permissions: string[]; 11}; 12 13export async function withApiAuth( 14 request: NextRequest, 15 handler: (req: NextRequest, ctx: ApiContext) => Promise<NextResponse>, 16 requiredPermissions: string[] = [] 17) { 18 const authHeader = request.headers.get('Authorization'); 19 20 if (!authHeader || !authHeader.startsWith('Bearer ')) { 21 return NextResponse.json( 22 { error: 'Missing or invalid Authorization header' }, 23 { status: 401 } 24 ); 25 } 26 27 const key = authHeader.replace('Bearer ', ''); 28 const result = await validateApiKey(key); 29 30 if (!result) { 31 return NextResponse.json( 32 { error: 'Invalid API key' }, 33 { status: 401 } 34 ); 35 } 36 37 // Check permissions 38 const hasPermission = requiredPermissions.every((p) => 39 result.apiKey.permissions.includes(p) 40 ); 41 42 if (!hasPermission) { 43 return NextResponse.json( 44 { error: 'Insufficient permissions' }, 45 { status: 403 } 46 ); 47 } 48 49 const ctx: ApiContext = { 50 user: { 51 id: result.user.id, 52 email: result.user.email, 53 }, 54 permissions: result.apiKey.permissions, 55 }; 56 57 return handler(request, ctx); 58}

Step 4: Implement Rate Limiting#

1// lib/rate-limit.ts 2import { LRUCache } from 'lru-cache'; 3 4type RateLimitOptions = { 5 interval: number; // in milliseconds 6 uniqueTokenPerInterval: number; 7}; 8 9export function rateLimit(options: RateLimitOptions) { 10 const tokenCache = new LRUCache<string, number[]>({ 11 max: options.uniqueTokenPerInterval, 12 ttl: options.interval, 13 }); 14 15 return { 16 check: (limit: number, token: string) => 17 new Promise<void>((resolve, reject) => { 18 const now = Date.now(); 19 const windowStart = now - options.interval; 20 21 const tokenCount = tokenCache.get(token) || []; 22 const validRequests = tokenCount.filter((t) => t > windowStart); 23 24 if (validRequests.length >= limit) { 25 reject(new Error('Rate limit exceeded')); 26 return; 27 } 28 29 validRequests.push(now); 30 tokenCache.set(token, validRequests); 31 resolve(); 32 }), 33 }; 34} 35 36// Pre-configured rate limiters 37export const apiRateLimiter = rateLimit({ 38 interval: 60 * 1000, // 1 minute 39 uniqueTokenPerInterval: 500, 40});

Install lru-cache:

npm install lru-cache

Rate Limit Middleware#

1// lib/api-middleware.ts 2import { NextRequest, NextResponse } from 'next/server'; 3import { apiRateLimiter } from './rate-limit'; 4 5export async function withRateLimit( 6 request: NextRequest, 7 handler: () => Promise<NextResponse>, 8 limit: number = 60 9) { 10 const ip = request.ip || request.headers.get('x-forwarded-for') || 'anonymous'; 11 const key = request.headers.get('Authorization') || ip; 12 13 try { 14 await apiRateLimiter.check(limit, key); 15 return handler(); 16 } catch { 17 return NextResponse.json( 18 { error: 'Too many requests' }, 19 { 20 status: 429, 21 headers: { 22 'Retry-After': '60', 23 'X-RateLimit-Limit': String(limit), 24 'X-RateLimit-Remaining': '0', 25 }, 26 } 27 ); 28 } 29}

Step 5: Create Validation Schemas#

1// lib/validations/tasks.ts 2import { z } from 'zod'; 3 4export const createTaskSchema = z.object({ 5 title: z.string().min(1).max(255), 6 description: z.string().max(5000).optional(), 7 status: z.enum(['todo', 'in_progress', 'done']).default('todo'), 8 priority: z.enum(['low', 'medium', 'high']).default('medium'), 9 dueDate: z.string().datetime().optional(), 10 projectId: z.string().cuid(), 11 assigneeId: z.string().cuid().optional(), 12}); 13 14export const updateTaskSchema = createTaskSchema.partial(); 15 16export const taskQuerySchema = z.object({ 17 projectId: z.string().cuid().optional(), 18 status: z.enum(['todo', 'in_progress', 'done']).optional(), 19 priority: z.enum(['low', 'medium', 'high']).optional(), 20 page: z.coerce.number().int().positive().default(1), 21 limit: z.coerce.number().int().min(1).max(100).default(20), 22 sort: z.enum(['createdAt', 'updatedAt', 'dueDate', 'priority']).default('createdAt'), 23 order: z.enum(['asc', 'desc']).default('desc'), 24}); 25 26export type CreateTaskInput = z.infer<typeof createTaskSchema>; 27export type UpdateTaskInput = z.infer<typeof updateTaskSchema>; 28export type TaskQuery = z.infer<typeof taskQuerySchema>;

Step 6: Build CRUD Endpoints#

Tasks API#

1// app/api/v1/tasks/route.ts 2import { NextRequest, NextResponse } from 'next/server'; 3import { prisma } from '@/lib/prisma'; 4import { withApiAuth, ApiContext } from '@/lib/api-auth'; 5import { withRateLimit } from '@/lib/api-middleware'; 6import { createTaskSchema, taskQuerySchema } from '@/lib/validations/tasks'; 7 8// GET /api/v1/tasks 9export async function GET(request: NextRequest) { 10 return withRateLimit(request, () => 11 withApiAuth(request, async (req, ctx) => { 12 const { searchParams } = new URL(req.url); 13 const queryResult = taskQuerySchema.safeParse( 14 Object.fromEntries(searchParams) 15 ); 16 17 if (!queryResult.success) { 18 return NextResponse.json( 19 { error: 'Invalid query parameters', details: queryResult.error.issues }, 20 { status: 400 } 21 ); 22 } 23 24 const { page, limit, sort, order, ...filters } = queryResult.data; 25 const skip = (page - 1) * limit; 26 27 const where = { 28 project: { userId: ctx.user.id }, 29 ...filters, 30 }; 31 32 const [tasks, total] = await Promise.all([ 33 prisma.task.findMany({ 34 where, 35 skip, 36 take: limit, 37 orderBy: { [sort]: order }, 38 include: { 39 project: { select: { id: true, name: true } }, 40 assignee: { select: { id: true, name: true } }, 41 }, 42 }), 43 prisma.task.count({ where }), 44 ]); 45 46 return NextResponse.json({ 47 data: tasks, 48 meta: { 49 page, 50 limit, 51 total, 52 totalPages: Math.ceil(total / limit), 53 }, 54 }); 55 }, ['read']) 56 ); 57} 58 59// POST /api/v1/tasks 60export async function POST(request: NextRequest) { 61 return withRateLimit(request, () => 62 withApiAuth(request, async (req, ctx) => { 63 const body = await req.json(); 64 const result = createTaskSchema.safeParse(body); 65 66 if (!result.success) { 67 return NextResponse.json( 68 { error: 'Validation failed', details: result.error.issues }, 69 { status: 400 } 70 ); 71 } 72 73 // Verify project ownership 74 const project = await prisma.project.findFirst({ 75 where: { 76 id: result.data.projectId, 77 userId: ctx.user.id, 78 }, 79 }); 80 81 if (!project) { 82 return NextResponse.json( 83 { error: 'Project not found' }, 84 { status: 404 } 85 ); 86 } 87 88 const task = await prisma.task.create({ 89 data: result.data, 90 include: { 91 project: { select: { id: true, name: true } }, 92 }, 93 }); 94 95 return NextResponse.json({ data: task }, { status: 201 }); 96 }, ['write']) 97 ); 98}

Single Task Endpoint#

1// app/api/v1/tasks/[id]/route.ts 2import { NextRequest, NextResponse } from 'next/server'; 3import { prisma } from '@/lib/prisma'; 4import { withApiAuth } from '@/lib/api-auth'; 5import { withRateLimit } from '@/lib/api-middleware'; 6import { updateTaskSchema } from '@/lib/validations/tasks'; 7 8// GET /api/v1/tasks/:id 9export async function GET( 10 request: NextRequest, 11 { params }: { params: { id: string } } 12) { 13 return withRateLimit(request, () => 14 withApiAuth(request, async (req, ctx) => { 15 const task = await prisma.task.findFirst({ 16 where: { 17 id: params.id, 18 project: { userId: ctx.user.id }, 19 }, 20 include: { 21 project: { select: { id: true, name: true } }, 22 assignee: { select: { id: true, name: true } }, 23 comments: { 24 orderBy: { createdAt: 'desc' }, 25 take: 10, 26 }, 27 }, 28 }); 29 30 if (!task) { 31 return NextResponse.json( 32 { error: 'Task not found' }, 33 { status: 404 } 34 ); 35 } 36 37 return NextResponse.json({ data: task }); 38 }, ['read']) 39 ); 40} 41 42// PATCH /api/v1/tasks/:id 43export async function PATCH( 44 request: NextRequest, 45 { params }: { params: { id: string } } 46) { 47 return withRateLimit(request, () => 48 withApiAuth(request, async (req, ctx) => { 49 const body = await req.json(); 50 const result = updateTaskSchema.safeParse(body); 51 52 if (!result.success) { 53 return NextResponse.json( 54 { error: 'Validation failed', details: result.error.issues }, 55 { status: 400 } 56 ); 57 } 58 59 // Verify ownership 60 const existing = await prisma.task.findFirst({ 61 where: { 62 id: params.id, 63 project: { userId: ctx.user.id }, 64 }, 65 }); 66 67 if (!existing) { 68 return NextResponse.json( 69 { error: 'Task not found' }, 70 { status: 404 } 71 ); 72 } 73 74 const task = await prisma.task.update({ 75 where: { id: params.id }, 76 data: result.data, 77 include: { 78 project: { select: { id: true, name: true } }, 79 }, 80 }); 81 82 return NextResponse.json({ data: task }); 83 }, ['write']) 84 ); 85} 86 87// DELETE /api/v1/tasks/:id 88export async function DELETE( 89 request: NextRequest, 90 { params }: { params: { id: string } } 91) { 92 return withRateLimit(request, () => 93 withApiAuth(request, async (req, ctx) => { 94 const existing = await prisma.task.findFirst({ 95 where: { 96 id: params.id, 97 project: { userId: ctx.user.id }, 98 }, 99 }); 100 101 if (!existing) { 102 return NextResponse.json( 103 { error: 'Task not found' }, 104 { status: 404 } 105 ); 106 } 107 108 await prisma.task.delete({ 109 where: { id: params.id }, 110 }); 111 112 return new NextResponse(null, { status: 204 }); 113 }, ['write']) 114 ); 115}

Step 7: Add Error Handling#

1// lib/api-errors.ts 2export class ApiError extends Error { 3 constructor( 4 public statusCode: number, 5 message: string, 6 public code?: string, 7 public details?: unknown 8 ) { 9 super(message); 10 this.name = 'ApiError'; 11 } 12} 13 14export class NotFoundError extends ApiError { 15 constructor(resource: string) { 16 super(404, `${resource} not found`, 'NOT_FOUND'); 17 } 18} 19 20export class ValidationError extends ApiError { 21 constructor(details: unknown) { 22 super(400, 'Validation failed', 'VALIDATION_ERROR', details); 23 } 24} 25 26export class UnauthorizedError extends ApiError { 27 constructor(message = 'Unauthorized') { 28 super(401, message, 'UNAUTHORIZED'); 29 } 30} 31 32export class ForbiddenError extends ApiError { 33 constructor(message = 'Forbidden') { 34 super(403, message, 'FORBIDDEN'); 35 } 36} 37 38export class RateLimitError extends ApiError { 39 constructor() { 40 super(429, 'Too many requests', 'RATE_LIMIT_EXCEEDED'); 41 } 42} 43 44export function handleApiError(error: unknown) { 45 if (error instanceof ApiError) { 46 return { 47 error: error.message, 48 code: error.code, 49 details: error.details, 50 }; 51 } 52 53 console.error('Unexpected API error:', error); 54 55 return { 56 error: 'Internal server error', 57 code: 'INTERNAL_ERROR', 58 }; 59}

Step 8: Create API Documentation#

1// app/api/v1/openapi.json/route.ts 2import { NextResponse } from 'next/server'; 3 4const openApiSpec = { 5 openapi: '3.0.0', 6 info: { 7 title: 'Task Management API', 8 version: '1.0.0', 9 description: 'RESTful API for task management', 10 }, 11 servers: [ 12 { 13 url: '/api/v1', 14 description: 'API v1', 15 }, 16 ], 17 security: [ 18 { 19 bearerAuth: [], 20 }, 21 ], 22 components: { 23 securitySchemes: { 24 bearerAuth: { 25 type: 'http', 26 scheme: 'bearer', 27 bearerFormat: 'API Key', 28 }, 29 }, 30 schemas: { 31 Task: { 32 type: 'object', 33 properties: { 34 id: { type: 'string' }, 35 title: { type: 'string' }, 36 description: { type: 'string' }, 37 status: { type: 'string', enum: ['todo', 'in_progress', 'done'] }, 38 priority: { type: 'string', enum: ['low', 'medium', 'high'] }, 39 dueDate: { type: 'string', format: 'date-time' }, 40 createdAt: { type: 'string', format: 'date-time' }, 41 updatedAt: { type: 'string', format: 'date-time' }, 42 }, 43 }, 44 Error: { 45 type: 'object', 46 properties: { 47 error: { type: 'string' }, 48 code: { type: 'string' }, 49 details: { type: 'object' }, 50 }, 51 }, 52 }, 53 }, 54 paths: { 55 '/tasks': { 56 get: { 57 summary: 'List tasks', 58 parameters: [ 59 { name: 'projectId', in: 'query', schema: { type: 'string' } }, 60 { name: 'status', in: 'query', schema: { type: 'string' } }, 61 { name: 'page', in: 'query', schema: { type: 'integer' } }, 62 { name: 'limit', in: 'query', schema: { type: 'integer' } }, 63 ], 64 responses: { 65 200: { 66 description: 'List of tasks', 67 content: { 68 'application/json': { 69 schema: { 70 type: 'object', 71 properties: { 72 data: { 73 type: 'array', 74 items: { $ref: '#/components/schemas/Task' }, 75 }, 76 meta: { 77 type: 'object', 78 properties: { 79 page: { type: 'integer' }, 80 limit: { type: 'integer' }, 81 total: { type: 'integer' }, 82 }, 83 }, 84 }, 85 }, 86 }, 87 }, 88 }, 89 }, 90 }, 91 post: { 92 summary: 'Create task', 93 requestBody: { 94 content: { 95 'application/json': { 96 schema: { $ref: '#/components/schemas/Task' }, 97 }, 98 }, 99 }, 100 responses: { 101 201: { description: 'Task created' }, 102 400: { description: 'Validation error' }, 103 }, 104 }, 105 }, 106 }, 107}; 108 109export async function GET() { 110 return NextResponse.json(openApiSpec); 111}

Step 9: Test the API#

Using curl#

1# Create API key (via dashboard or CLI) 2API_KEY="bsk_your_api_key" 3 4# List tasks 5curl -H "Authorization: Bearer $API_KEY" \ 6 http://localhost:3000/api/v1/tasks 7 8# Create task 9curl -X POST \ 10 -H "Authorization: Bearer $API_KEY" \ 11 -H "Content-Type: application/json" \ 12 -d '{"title": "Test task", "projectId": "..."}' \ 13 http://localhost:3000/api/v1/tasks 14 15# Update task 16curl -X PATCH \ 17 -H "Authorization: Bearer $API_KEY" \ 18 -H "Content-Type: application/json" \ 19 -d '{"status": "done"}' \ 20 http://localhost:3000/api/v1/tasks/{id} 21 22# Delete task 23curl -X DELETE \ 24 -H "Authorization: Bearer $API_KEY" \ 25 http://localhost:3000/api/v1/tasks/{id}

Verification Checklist#

  • API key authentication works
  • Rate limiting enforced
  • Validation errors return proper messages
  • CRUD operations work correctly
  • Pagination works
  • Error handling is consistent

Security Review#

bootspring agent invoke security-expert "Review the REST API for security vulnerabilities"

What You Learned#

  • API key authentication
  • Rate limiting implementation
  • Request validation with Zod
  • RESTful design patterns
  • Error handling strategies
  • OpenAPI documentation

Next Steps#