| 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
dependencies for express, fastify, @fastify/, koa, etc.
- Language: Check for
tsconfig.json, file extensions in src/.
- 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();
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const query = listProductsQuerySchema.parse(req.query);
const { page, limit, sort, order, search } = query;
const offset = (page - 1) * limit;
const items: any[] = [];
const total = 0;
res.json({
data: items,
meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
});
} catch (err) { next(err); }
});
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = productIdSchema.parse(req.params);
const item = null;
if (!item) { res.status(404).json({ error: 'Product not found' }); return; }
res.json({ data: item });
} catch (err) { next(err); }
});
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const data = createProductSchema.parse(req.body);
const item = null;
res.status(201).json({ data: item });
} catch (err) { next(err); }
});
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;
if (!item) { res.status(404).json({ error: 'Product not found' }); return; }
res.json({ data: item });
} catch (err) { next(err); }
});
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = productIdSchema.parse(req.params);
const deleted = false;
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) => {
});
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) {
if (err instanceof ZodError) {
res.status(400).json({
error: 'Validation failed',
details: err.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
})),
});
return;
}
if ('statusCode' in err && typeof (err as any).statusCode === 'number') {
res.status((err as any).statusCode).json({ error: err.message });
return;
}
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';
const BASE_URL = '/api/products';
describe('Product API', () => {
describe('POST /api/products', () => {
it('should create a product with valid data', async () => {
});
it('should return 400 for invalid data', async () => {
});
});
describe('GET /api/products', () => {
it('should return paginated list', async () => {
});
it('should respect pagination params', async () => {
});
});
describe('GET /api/products/:id', () => {
it('should return a product by ID', async () => {
});
it('should return 404 for non-existent ID', async () => {
});
});
describe('PUT /api/products/:id', () => {
it('should update an existing product', async () => {
});
it('should allow partial updates', async () => {
});
});
describe('DELETE /api/products/:id', () => {
it('should delete an existing product', async () => {
});
it('should return 404 for non-existent ID', async () => {
});
});
});
7. Show Registration Instructions
After creating all files, show the user how to register the new route in their app entry point:
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"), use import/export. If CJS, use require/module.exports.
- Do not overwrite existing files. If a route file already exists, warn the user and ask before proceeding.