--- 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 `.schema.` in the appropriate directory. **TypeScript + Zod example** (`src/schemas/product.schema.ts`): ```typescript 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; export type UpdateProductInput = z.infer; export type ListProductsQuery = z.infer; ``` 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.`): ```typescript 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.`): ```typescript 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.`: ```typescript 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.`: ```typescript const requestCounts = new Map(); 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 `.test.` in the test directory (or alongside the route if no test dir exists): ```typescript 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: ```typescript // 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"`), 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.