$npx -y skills add vibeeval/vibecosystem --skill api-patternsAPI design, versioning, testing, schema validation, and contract testing patterns for REST and GraphQL APIs.
| 1 | # API Patterns |
| 2 | |
| 3 | REST and GraphQL API design patterns for consistent, versioned, and well-tested interfaces. |
| 4 | |
| 5 | ## API Versioning |
| 6 | |
| 7 | ### URL-Based Versioning |
| 8 | |
| 9 | ```typescript |
| 10 | // routes/v1/markets.ts |
| 11 | // routes/v2/markets.ts |
| 12 | // URL: /api/v1/markets, /api/v2/markets |
| 13 | |
| 14 | // Express router setup |
| 15 | import { Router } from 'express' |
| 16 | |
| 17 | const v1Router = Router() |
| 18 | const v2Router = Router() |
| 19 | |
| 20 | app.use('/api/v1', v1Router) |
| 21 | app.use('/api/v2', v2Router) |
| 22 | |
| 23 | // Deprecation header middleware |
| 24 | function deprecationWarning(version: string, sunsetDate: string) { |
| 25 | return (_req: Request, res: Response, next: NextFunction) => { |
| 26 | res.setHeader('Deprecation', 'true') |
| 27 | res.setHeader('Sunset', sunsetDate) |
| 28 | res.setHeader('Link', `</api/v${parseInt(version) + 1}>; rel="successor-version"`) |
| 29 | next() |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | v1Router.use(deprecationWarning('1', 'Sat, 01 Jan 2027 00:00:00 GMT')) |
| 34 | ``` |
| 35 | |
| 36 | ### Header-Based Versioning |
| 37 | |
| 38 | ```typescript |
| 39 | // Accept: application/vnd.api+json;version=2 |
| 40 | function versionMiddleware(req: Request, res: Response, next: NextFunction) { |
| 41 | const accept = req.headers['accept'] || '' |
| 42 | const match = accept.match(/version=(\d+)/) |
| 43 | req.apiVersion = match ? parseInt(match[1]) : 1 |
| 44 | next() |
| 45 | } |
| 46 | ``` |
| 47 | |
| 48 | ## Schema Validation with Zod |
| 49 | |
| 50 | ### Request + Response Validation |
| 51 | |
| 52 | ```typescript |
| 53 | import { z } from 'zod' |
| 54 | |
| 55 | // Request schema |
| 56 | const CreateMarketSchema = z.object({ |
| 57 | name: z.string().min(1).max(200), |
| 58 | description: z.string().max(2000).optional(), |
| 59 | category: z.enum(['sports', 'politics', 'crypto', 'tech']), |
| 60 | closeAt: z.string().datetime(), |
| 61 | initialLiquidity: z.number().positive().max(1_000_000) |
| 62 | }) |
| 63 | |
| 64 | // Response schema - strip internal fields |
| 65 | const MarketResponseSchema = z.object({ |
| 66 | id: z.string().uuid(), |
| 67 | name: z.string(), |
| 68 | category: z.string(), |
| 69 | status: z.enum(['open', 'closed', 'resolved']), |
| 70 | volume: z.number(), |
| 71 | createdAt: z.string().datetime() |
| 72 | }) |
| 73 | |
| 74 | type CreateMarketDto = z.infer<typeof CreateMarketSchema> |
| 75 | type MarketResponse = z.infer<typeof MarketResponseSchema> |
| 76 | |
| 77 | // Validation middleware |
| 78 | function validate<T>(schema: z.ZodSchema<T>) { |
| 79 | return (req: Request, res: Response, next: NextFunction) => { |
| 80 | const result = schema.safeParse(req.body) |
| 81 | if (!result.success) { |
| 82 | return res.status(400).json({ |
| 83 | success: false, |
| 84 | error: 'Validation failed', |
| 85 | code: 'VALIDATION_ERROR', |
| 86 | details: result.error.flatten() |
| 87 | }) |
| 88 | } |
| 89 | req.validated = result.data |
| 90 | next() |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // Usage |
| 95 | router.post('/markets', validate(CreateMarketSchema), async (req, res) => { |
| 96 | const dto = req.validated as CreateMarketDto |
| 97 | const market = await marketService.create(dto) |
| 98 | const response = MarketResponseSchema.parse(market) |
| 99 | res.status(201).json({ success: true, data: response }) |
| 100 | }) |
| 101 | ``` |
| 102 | |
| 103 | ## Standardized Error Responses |
| 104 | |
| 105 | ```typescript |
| 106 | // Always: { success, error, code, details? } |
| 107 | interface ApiError { |
| 108 | success: false |
| 109 | error: string |
| 110 | code: string |
| 111 | details?: unknown |
| 112 | requestId?: string |
| 113 | } |
| 114 | |
| 115 | interface ApiSuccess<T> { |
| 116 | success: true |
| 117 | data: T |
| 118 | meta?: { total?: number; page?: number; limit?: number } |
| 119 | } |
| 120 | |
| 121 | const ERROR_CODES = { |
| 122 | VALIDATION_ERROR: 400, |
| 123 | UNAUTHORIZED: 401, |
| 124 | FORBIDDEN: 403, |
| 125 | NOT_FOUND: 404, |
| 126 | CONFLICT: 409, |
| 127 | RATE_LIMITED: 429, |
| 128 | INTERNAL: 500 |
| 129 | } as const |
| 130 | |
| 131 | function apiError( |
| 132 | res: Response, |
| 133 | code: keyof typeof ERROR_CODES, |
| 134 | message: string, |
| 135 | details?: unknown |
| 136 | ): Response { |
| 137 | return res.status(ERROR_CODES[code]).json({ |
| 138 | success: false, |
| 139 | error: message, |
| 140 | code, |
| 141 | details, |
| 142 | requestId: res.locals.requestId |
| 143 | } satisfies ApiError) |
| 144 | } |
| 145 | ``` |
| 146 | |
| 147 | ## Pagination Patterns |
| 148 | |
| 149 | ### Cursor-Based Pagination (Recommended for large datasets) |
| 150 | |
| 151 | ```typescript |
| 152 | interface CursorPage<T> { |
| 153 | items: T[] |
| 154 | nextCursor: string | null |
| 155 | prevCursor: string | null |
| 156 | hasMore: boolean |
| 157 | } |
| 158 | |
| 159 | async function paginateWithCursor<T extends { id: string; createdAt: Date }>( |
| 160 | query: (cursor: string | null, limit: number) => Promise<T[]>, |
| 161 | cursor: string | null, |
| 162 | limit = 20 |
| 163 | ): Promise<CursorPage<T>> { |
| 164 | // Fetch one extra to detect hasMore |
| 165 | const items = await query(cursor, limit + 1) |
| 166 | const hasMore = items.length > limit |
| 167 | const page = hasMore ? items.slice(0, limit) : items |
| 168 | |
| 169 | return { |
| 170 | items: page, |
| 171 | nextCursor: hasMore ? Buffer.from(page[page.length - 1].id).toString('base64') : null, |
| 172 | prevCursor: cursor, |
| 173 | hasMore |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // GET /api/markets?cursor=<base64>&limit=20 |
| 178 | router.get('/markets', async (req, res) => { |
| 179 | const cursor = req.query.cursor as string | null |
| 180 | const limit = Math.min(parseInt(req.query.limit as string) || 20, 100) |
| 181 | const decoded = cursor ? Buffer.from(cursor, 'base64').toString() : null |
| 182 | |
| 183 | const page = await paginateWithCursor( |
| 184 | (c, l) => db.market.findMany({ |
| 185 | take: l, |
| 186 | skip: c ? 1 : 0, |
| 187 | cursor: c ? { id: c } : undefined, |
| 188 | orderBy: { createdAt: 'desc' } |
| 189 | }), |
| 190 | decoded, |
| 191 | limit |
| 192 | ) |
| 193 | |
| 194 | res.json({ success: true, ...page }) |
| 195 | }) |
| 196 | ``` |
| 197 | |
| 198 | ### Offset Paginatio |