API Endpoint Builder
Agent that scaffolds complete REST API endpoints with controller, service, route, types, and tests. Supports Express, Fastify, and NestJS.
API Endpoint Builder
Overview
An agent that scaffolds production-ready REST API endpoints with proper validation, error handling, authentication, database integration, and tests. Give it a resource name and it generates the full vertical slice β route, controller, service, model, validation schema, tests, and documentation.
Quick Start
# Generate a complete CRUD endpoint claude "Build a REST API for managing products with CRUD operations" # Generate with specific requirements claude "Create a /api/v1/orders endpoint with pagination, filtering by status, and Stripe payment integration"
What Gets Generated
For each resource, the agent creates a complete vertical slice:
src/
βββ routes/
β βββ products.ts # Route definitions with middleware
βββ controllers/
β βββ productsController.ts # Request handling, response formatting
βββ services/
β βββ productsService.ts # Business logic, database calls
βββ models/
β βββ Product.ts # Data model / schema definition
βββ validators/
β βββ productValidators.ts # Input validation schemas
βββ middleware/
β βββ productMiddleware.ts # Resource-specific middleware
βββ types/
β βββ product.ts # TypeScript interfaces
βββ tests/
βββ products.test.ts # Integration tests
βββ productsService.test.ts # Unit tests
Architecture Patterns
Route Layer
Defines HTTP endpoints and wires middleware:
// routes/products.ts import { Router } from 'express'; import { authenticate, authorize } from '../middleware/auth'; import { validate } from '../middleware/validate'; import { productValidators } from '../validators/productValidators'; import * as controller from '../controllers/productsController'; const router = Router(); router.get('/', authenticate, validate(productValidators.list), controller.listProducts ); router.get('/:id', authenticate, validate(productValidators.getById), controller.getProduct ); router.post('/', authenticate, authorize('admin', 'manager'), validate(productValidators.create), controller.createProduct ); router.put('/:id', authenticate, authorize('admin', 'manager'), validate(productValidators.update), controller.updateProduct ); router.delete('/:id', authenticate, authorize('admin'), controller.deleteProduct ); export default router;
Controller Layer
Handles HTTP concerns β request parsing, response formatting, status codes:
// controllers/productsController.ts import { Request, Response, NextFunction } from 'express'; import * as productService from '../services/productsService'; export async function listProducts(req: Request, res: Response, next: NextFunction) { try { const { page = 1, limit = 20, sort, status, category } = req.query; const result = await productService.list({ page: Number(page), limit: Math.min(Number(limit), 100), // Cap at 100 sort: String(sort || '-createdAt'), filters: { ...(status && { status: String(status) }), ...(category && { category: String(category) }), }, }); res.json({ data: result.items, pagination: { page: result.page, limit: result.limit, total: result.total, totalPages: result.totalPages, hasNext: result.page < result.totalPages, }, }); } catch (error) { next(error); } } export async function createProduct(req: Request, res: Response, next: NextFunction) { try { const product = await productService.create(req.body, req.user.id); res.status(201).json({ data: product }); } catch (error) { next(error); } } export async function getProduct(req: Request, res: Response, next: NextFunction) { try { const product = await productService.getById(req.params.id); if (!product) { return res.status(404).json({ error: 'Product not found' }); } res.json({ data: product }); } catch (error) { next(error); } } export async function updateProduct(req: Request, res: Response, next: NextFunction) { try { const product = await productService.update(req.params.id, req.body, req.user.id); if (!product) { return res.status(404).json({ error: 'Product not found' }); } res.json({ data: product }); } catch (error) { next(error); } } export async function deleteProduct(req: Request, res: Response, next: NextFunction) { try { const deleted = await productService.softDelete(req.params.id, req.user.id); if (!deleted) { return res.status(404).json({ error: 'Product not found' }); } res.status(204).send(); } catch (error) { next(error); } }
Service Layer
Contains business logic, independent of HTTP:
// services/productsService.ts import { Product } from '../models/Product'; import { CreateProductInput, UpdateProductInput, ListOptions } from '../types/product'; export async function list(options: ListOptions) { const { page, limit, sort, filters } = options; const skip = (page - 1) * limit; const query = Product.find({ deletedAt: null, ...filters }); // Dynamic sorting const sortField = sort.startsWith('-') ? sort.slice(1) : sort; const sortOrder = sort.startsWith('-') ? -1 : 1; query.sort({ [sortField]: sortOrder }); const [items, total] = await Promise.all([ query.skip(skip).limit(limit).lean(), Product.countDocuments({ deletedAt: null, ...filters }), ]); return { items, page, limit, total, totalPages: Math.ceil(total / limit), }; } export async function getById(id: string) { return Product.findOne({ _id: id, deletedAt: null }).lean(); } export async function create(input: CreateProductInput, userId: string) { const product = new Product({ ...input, createdBy: userId, updatedBy: userId, }); return product.save(); } export async function update(id: string, input: UpdateProductInput, userId: string) { return Product.findOneAndUpdate( { _id: id, deletedAt: null }, { ...input, updatedBy: userId, updatedAt: new Date() }, { new: true, runValidators: true } ).lean(); } export async function softDelete(id: string, userId: string) { return Product.findOneAndUpdate( { _id: id, deletedAt: null }, { deletedAt: new Date(), updatedBy: userId }, { new: true } ); }
Validation Layer
Input validation using Zod (or Joi):
// validators/productValidators.ts import { z } from 'zod'; export const productValidators = { create: z.object({ body: z.object({ name: z.string().min(1).max(200), description: z.string().max(5000).optional(), price: z.number().positive().max(999999.99), currency: z.enum(['USD', 'EUR', 'GBP']).default('USD'), category: z.string().min(1), tags: z.array(z.string()).max(10).optional(), sku: z.string().regex(/^[A-Z0-9-]+$/).optional(), inventory: z.number().int().min(0).default(0), status: z.enum(['draft', 'active', 'archived']).default('draft'), images: z.array(z.string().url()).max(10).optional(), }), }), update: z.object({ params: z.object({ id: z.string() }), body: z.object({ name: z.string().min(1).max(200).optional(), description: z.string().max(5000).optional(), price: z.number().positive().max(999999.99).optional(), category: z.string().optional(), tags: z.array(z.string()).max(10).optional(), status: z.enum(['draft', 'active', 'archived']).optional(), }), }), list: z.object({ query: z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().positive().max(100).default(20), sort: z.string().optional(), status: z.enum(['draft', 'active', 'archived']).optional(), category: z.string().optional(), }), }), getById: z.object({ params: z.object({ id: z.string() }), }), };
Error Handling
Centralized error handling middleware:
// middleware/errorHandler.ts import { Request, Response, NextFunction } from 'express'; class AppError extends Error { statusCode: number; isOperational: boolean; constructor(message: string, statusCode: number) { super(message); this.statusCode = statusCode; this.isOperational = true; } } export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) { if (err instanceof AppError) { return res.status(err.statusCode).json({ error: err.message, }); } // Validation errors (Zod) if (err.name === 'ZodError') { return res.status(400).json({ error: 'Validation failed', details: (err as any).errors, }); } // MongoDB duplicate key if ((err as any).code === 11000) { return res.status(409).json({ error: 'Resource already exists', }); } // Unexpected errors console.error('Unhandled error:', err); res.status(500).json({ error: 'Internal server error', }); }
Response Format
Consistent JSON response envelope:
// Success (single item) { "data": { "id": "123", "name": "Widget", "price": 29.99 } } // Success (list with pagination) { "data": [ { "id": "123", "name": "Widget", "price": 29.99 }, { "id": "124", "name": "Gadget", "price": 49.99 } ], "pagination": { "page": 1, "limit": 20, "total": 156, "totalPages": 8, "hasNext": true } } // Error { "error": "Product not found" } // Validation error { "error": "Validation failed", "details": [ { "field": "price", "message": "Must be a positive number" }, { "field": "name", "message": "Required" } ] }
HTTP Status Codes
| Code | When to Use |
|---|---|
200 | Successful GET, PUT, PATCH |
201 | Successful POST (resource created) |
204 | Successful DELETE (no content) |
400 | Invalid request / validation failed |
401 | Not authenticated |
403 | Authenticated but not authorized |
404 | Resource not found |
409 | Conflict (duplicate resource) |
422 | Valid syntax but semantically wrong |
429 | Rate limit exceeded |
500 | Unexpected server error |
Pagination Patterns
Offset-based (Simple)
GET /api/products?page=2&limit=20
Best for: Admin dashboards, small datasets, when total count matters.
Cursor-based (Scalable)
GET /api/products?cursor=eyJpZCI6MTIzfQ&limit=20
Best for: Infinite scroll, large datasets, real-time feeds.
// Cursor-based implementation export async function listWithCursor(cursor?: string, limit = 20) { const query: any = { deletedAt: null }; if (cursor) { const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString()); query._id = { $gt: decoded.id }; } const items = await Product.find(query).limit(limit + 1).sort({ _id: 1 }).lean(); const hasNext = items.length > limit; if (hasNext) items.pop(); const nextCursor = hasNext ? Buffer.from(JSON.stringify({ id: items[items.length - 1]._id })).toString('base64') : null; return { items, nextCursor, hasNext }; }
Filtering & Sorting
GET /api/products?status=active&category=electronics&sort=-price&minPrice=10&maxPrice=100
// Build dynamic filters from query params function buildFilters(query: Record<string, any>) { const filters: any = { deletedAt: null }; if (query.status) filters.status = query.status; if (query.category) filters.category = query.category; if (query.search) { filters.$text = { $search: query.search }; } if (query.minPrice || query.maxPrice) { filters.price = {}; if (query.minPrice) filters.price.$gte = Number(query.minPrice); if (query.maxPrice) filters.price.$lte = Number(query.maxPrice); } return filters; }
Testing
Integration Tests
import request from 'supertest'; import app from '../app'; describe('POST /api/products', () => { it('creates a product with valid input', async () => { const res = await request(app) .post('/api/products') .set('Authorization', `Bearer ${adminToken}`) .send({ name: 'Test Product', price: 29.99, category: 'test' }); expect(res.status).toBe(201); expect(res.body.data).toMatchObject({ name: 'Test Product', price: 29.99, }); }); it('rejects invalid price', async () => { const res = await request(app) .post('/api/products') .set('Authorization', `Bearer ${adminToken}`) .send({ name: 'Bad Product', price: -5, category: 'test' }); expect(res.status).toBe(400); expect(res.body.error).toBe('Validation failed'); }); it('requires authentication', async () => { const res = await request(app) .post('/api/products') .send({ name: 'No Auth', price: 10, category: 'test' }); expect(res.status).toBe(401); }); }); describe('GET /api/products', () => { it('returns paginated results', async () => { const res = await request(app) .get('/api/products?page=1&limit=10') .set('Authorization', `Bearer ${userToken}`); expect(res.status).toBe(200); expect(res.body.data).toBeInstanceOf(Array); expect(res.body.pagination).toHaveProperty('total'); expect(res.body.pagination).toHaveProperty('totalPages'); }); it('filters by status', async () => { const res = await request(app) .get('/api/products?status=active') .set('Authorization', `Bearer ${userToken}`); expect(res.body.data.every(p => p.status === 'active')).toBe(true); }); });
Best Practices
- Validate early β Check input at the route level before hitting the service layer
- Use soft deletes β Set
deletedAtinstead of removing records permanently - Version your API β Use
/api/v1/prefix for future compatibility - Rate limit β Protect endpoints from abuse (especially auth and writes)
- Log requests β Include request ID, user, method, path, duration, status
- Use transactions β Wrap multi-step operations in database transactions
- Idempotent operations β PUT and DELETE should be safe to retry
- HATEOAS links β Include relevant links in responses for discoverability
- Health check β Always include
GET /healthfor monitoring - API documentation β Auto-generate from validation schemas (OpenAPI/Swagger)
Reviews
No reviews yet. Be the first to review this template!
Similar Templates
Documentation Auto-Generator
Agent that reads your codebase and generates comprehensive documentation including API docs, architecture guides, and setup instructions.
Ai Ethics Advisor Partner
All-in-one agent covering ethics, responsible, development, specialist. Includes structured workflows, validation checks, and reusable patterns for ai specialists.
Guide Hackathon Navigator
Streamline your workflow with this expert, hackathon, strategist, judge. Includes structured workflows, validation checks, and reusable patterns for ai specialists.