A

API Endpoint Builder

Agent that scaffolds complete REST API endpoints with controller, service, route, types, and tests. Supports Express, Fastify, and NestJS.

AgentClipticsdevelopmentv1.0.0MIT
1 views0 copies

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

CodeWhen to Use
200Successful GET, PUT, PATCH
201Successful POST (resource created)
204Successful DELETE (no content)
400Invalid request / validation failed
401Not authenticated
403Authenticated but not authorized
404Resource not found
409Conflict (duplicate resource)
422Valid syntax but semantically wrong
429Rate limit exceeded
500Unexpected 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

  1. Validate early β€” Check input at the route level before hitting the service layer
  2. Use soft deletes β€” Set deletedAt instead of removing records permanently
  3. Version your API β€” Use /api/v1/ prefix for future compatibility
  4. Rate limit β€” Protect endpoints from abuse (especially auth and writes)
  5. Log requests β€” Include request ID, user, method, path, duration, status
  6. Use transactions β€” Wrap multi-step operations in database transactions
  7. Idempotent operations β€” PUT and DELETE should be safe to retry
  8. HATEOAS links β€” Include relevant links in responses for discoverability
  9. Health check β€” Always include GET /health for monitoring
  10. API documentation β€” Auto-generate from validation schemas (OpenAPI/Swagger)
Community

Reviews

Write a review

No reviews yet. Be the first to review this template!

Similar Templates