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-keysStep 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-cacheRate 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