name: api-scaffold description: Scaffold a production-ready REST API endpoint with CRUD operations, input validation, error handling, rate limiting, and test skeletons. Supports Express and Fastify.
API Scaffold
Scaffold a complete REST API resource with CRUD endpoints, validation, error handling, and tests.
Inputs
When invoked, determine these from the user's request:
- Resource name (required): e.g., "user", "product", "order". Singularize if plural.
- Framework (optional, default: auto-detect from
package.json): "express" or "fastify". - Language (optional, default: auto-detect from project): "typescript" or "javascript".
- Fields (optional): If the user specifies fields, use them. Otherwise, create a sensible default schema with
id,createdAt,updatedAt, and 2-3 domain-relevant fields.
Steps
1. Detect Project Setup
Run these commands to understand the project:
cat package.json
ls src/ || ls app/ || ls .
Determine:
- Framework: Check
dependenciesforexpress,fastify,@fastify/,koa, etc. - Language: Check for
tsconfig.json, file extensions insrc/. - Project structure: Identify where routes/controllers live (e.g.,
src/routes/,src/api/,routes/). - Existing patterns: Read one existing route file to match the project's conventions.
If no package.json exists, ask the user which framework and language to use.
2. Create the Validation Schema
Create <resource>.schema.<ext> in the appropriate directory.
TypeScript + Zod example (src/schemas/product.schema.ts):
import { z } from 'zod';
export const createProductSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
price: z.number().positive().finite(),
category: z.string().min(1).max(100),
inStock: z.boolean().default(true),
});
export const updateProductSchema = createProductSchema.partial();
export const productIdSchema = z.object({
id: z.string().uuid(),
});
export const listProductsQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['createdAt', 'name', 'price']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
search: z.string().max(255).optional(),
});
export type CreateProductInput = z.infer<typeof createProductSchema>;
export type UpdateProductInput = z.infer<typeof updateProductSchema>;
export type ListProductsQuery = z.infer<typeof listProductsQuerySchema>;
If the project uses Joi instead of Zod, use Joi syntax. If neither is installed, use Zod and note that the user should npm install zod.
3. Create the Route/Controller File
Create the route file following the detected project structure.
Express pattern (src/routes/product.routes.<ext>):
import { Router, Request, Response, NextFunction } from 'express';
const router = Router();
// GET /api/products - List with pagination, sorting, search
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const query = listProductsQuerySchema.parse(req.query);
// TODO: Replace with actual data source
const { page, limit, sort, order, search } = query;
const offset = (page - 1) * limit;
const items: any[] = []; // TODO: query database
const total = 0; // TODO: count query
res.json({
data: items,
meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
});
} catch (err) { next(err); }
});
// GET /api/products/:id - Get single resource
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = productIdSchema.parse(req.params);
const item = null; // TODO: find by id
if (!item) { res.status(404).json({ error: 'Product not found' }); return; }
res.json({ data: item });
} catch (err) { next(err); }
});
// POST /api/products - Create resource
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const data = createProductSchema.parse(req.body);
const item = null; // TODO: insert into database
res.status(201).json({ data: item });
} catch (err) { next(err); }
});
// PUT /api/products/:id - Update resource
router.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = productIdSchema.parse(req.params);
const data = updateProductSchema.parse(req.body);
const item = null; // TODO: update in database
if (!item) { res.status(404).json({ error: 'Product not found' }); return; }
res.json({ data: item });
} catch (err) { next(err); }
});
// DELETE /api/products/:id - Delete resource
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = productIdSchema.parse(req.params);
const deleted = false; // TODO: delete from database
if (!deleted) { res.status(404).json({ error: 'Product not found' }); return; }
res.status(204).send();
} catch (err) { next(err); }
});
export default router;
Fastify pattern (src/routes/product.routes.<ext>):
import { FastifyInstance } from 'fastify';
export default async function productRoutes(app: FastifyInstance) {
app.get('/', { schema: { querystring: listSchema } }, async (req, reply) => {
// ... pagination + list logic
});
app.get('/:id', async (req, reply) => { /* ... */ });
app.post('/', async (req, reply) => { /* ... */ });
app.put('/:id', async (req, reply) => { /* ... */ });
app.delete('/:id', async (req, reply) => { /* ... */ });
}
4. Create Error Handling Middleware
If the project does not already have centralized error handling, create src/middleware/errorHandler.<ext>:
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction) {
// Validation errors
if (err instanceof ZodError) {
res.status(400).json({
error: 'Validation failed',
details: err.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
})),
});
return;
}
// Known application errors
if ('statusCode' in err && typeof (err as any).statusCode === 'number') {
res.status((err as any).statusCode).json({ error: err.message });
return;
}
// Unknown errors - do not leak internals
console.error('[ERROR]', err);
res.status(500).json({ error: 'Internal server error' });
}
5. Create Rate Limiting Middleware
If not already present, create src/middleware/rateLimit.<ext>:
const requestCounts = new Map<string, { count: number; resetAt: number }>();
export function rateLimit({ windowMs = 60_000, max = 100 } = {}) {
return (req: any, res: any, next: any) => {
const key = req.ip || req.connection?.remoteAddress || 'unknown';
const now = Date.now();
const record = requestCounts.get(key);
if (!record || now > record.resetAt) {
requestCounts.set(key, { count: 1, resetAt: now + windowMs });
return next();
}
record.count++;
res.setHeader('X-RateLimit-Limit', String(max));
res.setHeader('X-RateLimit-Remaining', String(Math.max(0, max - record.count)));
if (record.count > max) {
res.status(429).json({ error: 'Too many requests. Please try again later.' });
return;
}
next();
};
}
Note: For production, recommend express-rate-limit or @fastify/rate-limit instead of this in-memory implementation.
6. Create Test Skeleton
Create <resource>.test.<ext> in the test directory (or alongside the route if no test dir exists):
import { describe, it, expect, beforeAll, afterAll } from 'vitest'; // or jest
// import supertest if available
const BASE_URL = '/api/products';
describe('Product API', () => {
describe('POST /api/products', () => {
it('should create a product with valid data', async () => {
// TODO: send POST request with valid body
// expect status 201
// expect response.data to match input
});
it('should return 400 for invalid data', async () => {
// TODO: send POST with missing required fields
// expect status 400
// expect response.error to be 'Validation failed'
});
});
describe('GET /api/products', () => {
it('should return paginated list', async () => {
// TODO: send GET request
// expect status 200
// expect response.data to be an array
// expect response.meta to have page, limit, total, totalPages
});
it('should respect pagination params', async () => {
// TODO: send GET with ?page=2&limit=5
// verify correct offset behavior
});
});
describe('GET /api/products/:id', () => {
it('should return a product by ID', async () => {
// TODO: create a product first, then fetch by ID
// expect status 200
});
it('should return 404 for non-existent ID', async () => {
// TODO: fetch with random UUID
// expect status 404
});
});
describe('PUT /api/products/:id', () => {
it('should update an existing product', async () => {
// TODO: create, then update
// expect status 200
// expect updated fields
});
it('should allow partial updates', async () => {
// TODO: send PUT with only one field
// expect other fields unchanged
});
});
describe('DELETE /api/products/:id', () => {
it('should delete an existing product', async () => {
// TODO: create, then delete
// expect status 204
});
it('should return 404 for non-existent ID', async () => {
// TODO: delete with random UUID
// expect status 404
});
});
});
7. Show Registration Instructions
After creating all files, show the user how to register the new route in their app entry point:
// In your main app file (e.g., src/app.ts or src/index.ts):
import productRoutes from './routes/product.routes';
app.use('/api/products', productRoutes);
8. Summary
Print a summary of all created files and any packages that need to be installed:
Created:
- src/schemas/product.schema.ts
- src/routes/product.routes.ts
- src/middleware/errorHandler.ts (if new)
- src/middleware/rateLimit.ts (if new)
- tests/product.test.ts
Install (if needed):
npm install zod
npm install -D vitest supertest @types/supertest
Next steps:
1. Register the route in your app entry point
2. Replace TODO comments with actual database logic
3. Run tests: npx vitest product
Notes
- Always match existing project conventions (naming, file structure, import style, semicolons, quotes).
- If the project uses ESM (
"type": "module"), useimport/export. If CJS, userequire/module.exports. - Do not overwrite existing files. If a route file already exists, warn the user and ask before proceeding.