agentic-loop 3.10.2 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,496 @@
1
+ ---
2
+ description: API design patterns for REST and GraphQL. Use when designing endpoints, handling errors, authentication, pagination, or versioning.
3
+ ---
4
+
5
+ # API Patterns
6
+
7
+ Best practices for designing and implementing APIs. Use these patterns when building REST or GraphQL endpoints.
8
+
9
+ ## REST Design
10
+
11
+ ### Resource Naming
12
+
13
+ ```
14
+ # ✅ Nouns, plural, hierarchical
15
+ GET /users # List users
16
+ POST /users # Create user
17
+ GET /users/:id # Get user
18
+ PATCH /users/:id # Update user
19
+ DELETE /users/:id # Delete user
20
+ GET /users/:id/orders # User's orders
21
+
22
+ # ❌ Avoid verbs, actions in URL
23
+ GET /getUsers
24
+ POST /createUser
25
+ POST /users/:id/activate # Use PATCH with status field instead
26
+ ```
27
+
28
+ ### HTTP Methods
29
+
30
+ | Method | Purpose | Idempotent | Request Body |
31
+ |--------|---------|------------|--------------|
32
+ | GET | Read | Yes | No |
33
+ | POST | Create | No | Yes |
34
+ | PUT | Replace | Yes | Yes |
35
+ | PATCH | Partial update | Yes | Yes |
36
+ | DELETE | Remove | Yes | No |
37
+
38
+ ### Status Codes
39
+
40
+ ```typescript
41
+ // ✅ Use appropriate status codes
42
+ return res.status(200).json(data); // OK - successful GET/PATCH
43
+ return res.status(201).json(created); // Created - successful POST
44
+ return res.status(204).send(); // No Content - successful DELETE
45
+ return res.status(400).json({ error }); // Bad Request - validation failed
46
+ return res.status(401).json({ error }); // Unauthorized - not authenticated
47
+ return res.status(403).json({ error }); // Forbidden - not authorized
48
+ return res.status(404).json({ error }); // Not Found
49
+ return res.status(409).json({ error }); // Conflict - duplicate, race condition
50
+ return res.status(422).json({ error }); // Unprocessable - semantic error
51
+ return res.status(429).json({ error }); // Too Many Requests - rate limited
52
+ return res.status(500).json({ error }); // Internal Error - unexpected
53
+ ```
54
+
55
+ ## Error Responses
56
+
57
+ ### Consistent Format
58
+
59
+ ```typescript
60
+ // ✅ Standard error shape
61
+ interface ApiError {
62
+ error: {
63
+ code: string; // Machine-readable: "VALIDATION_ERROR"
64
+ message: string; // Human-readable: "Invalid email format"
65
+ details?: unknown; // Optional field-level errors
66
+ requestId?: string; // For debugging
67
+ };
68
+ }
69
+
70
+ // Example response
71
+ {
72
+ "error": {
73
+ "code": "VALIDATION_ERROR",
74
+ "message": "Request validation failed",
75
+ "details": {
76
+ "email": "Invalid email format",
77
+ "age": "Must be a positive number"
78
+ },
79
+ "requestId": "req_abc123"
80
+ }
81
+ }
82
+ ```
83
+
84
+ ### Error Handler
85
+
86
+ ```typescript
87
+ // ✅ Centralized error handling
88
+ class AppError extends Error {
89
+ constructor(
90
+ public code: string,
91
+ public message: string,
92
+ public statusCode: number,
93
+ public details?: unknown
94
+ ) {
95
+ super(message);
96
+ }
97
+ }
98
+
99
+ // Common errors
100
+ const NotFoundError = (resource: string) =>
101
+ new AppError('NOT_FOUND', `${resource} not found`, 404);
102
+
103
+ const ValidationError = (details: Record<string, string>) =>
104
+ new AppError('VALIDATION_ERROR', 'Validation failed', 400, details);
105
+
106
+ const UnauthorizedError = () =>
107
+ new AppError('UNAUTHORIZED', 'Authentication required', 401);
108
+
109
+ // Express error middleware
110
+ app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
111
+ const requestId = req.headers['x-request-id'] || crypto.randomUUID();
112
+
113
+ if (err instanceof AppError) {
114
+ return res.status(err.statusCode).json({
115
+ error: {
116
+ code: err.code,
117
+ message: err.message,
118
+ details: err.details,
119
+ requestId
120
+ }
121
+ });
122
+ }
123
+
124
+ // Unexpected error - log full details, return generic message
125
+ console.error('Unhandled error:', { requestId, error: err });
126
+ return res.status(500).json({
127
+ error: {
128
+ code: 'INTERNAL_ERROR',
129
+ message: 'An unexpected error occurred',
130
+ requestId
131
+ }
132
+ });
133
+ });
134
+ ```
135
+
136
+ ## Pagination
137
+
138
+ ### Cursor-Based (Recommended)
139
+
140
+ ```typescript
141
+ // ✅ For large/real-time datasets
142
+ interface PaginatedResponse<T> {
143
+ data: T[];
144
+ pagination: {
145
+ hasMore: boolean;
146
+ nextCursor?: string; // Opaque token
147
+ };
148
+ }
149
+
150
+ // Request
151
+ GET /users?limit=20&cursor=eyJpZCI6MTIzfQ
152
+
153
+ // Implementation
154
+ async function listUsers(limit: number, cursor?: string) {
155
+ const decoded = cursor ? JSON.parse(atob(cursor)) : null;
156
+
157
+ const users = await db.query({
158
+ where: decoded ? { id: { gt: decoded.id } } : undefined,
159
+ orderBy: { id: 'asc' },
160
+ take: limit + 1 // Fetch one extra to check hasMore
161
+ });
162
+
163
+ const hasMore = users.length > limit;
164
+ const data = hasMore ? users.slice(0, -1) : users;
165
+ const nextCursor = hasMore
166
+ ? btoa(JSON.stringify({ id: data[data.length - 1].id }))
167
+ : undefined;
168
+
169
+ return { data, pagination: { hasMore, nextCursor } };
170
+ }
171
+ ```
172
+
173
+ ### Offset-Based (Simple)
174
+
175
+ ```typescript
176
+ // ✅ For small, static datasets
177
+ interface PaginatedResponse<T> {
178
+ data: T[];
179
+ pagination: {
180
+ total: number;
181
+ page: number;
182
+ pageSize: number;
183
+ totalPages: number;
184
+ };
185
+ }
186
+
187
+ // Request
188
+ GET /users?page=2&pageSize=20
189
+
190
+ // Implementation
191
+ async function listUsers(page: number, pageSize: number) {
192
+ const [data, total] = await Promise.all([
193
+ db.users.findMany({ skip: (page - 1) * pageSize, take: pageSize }),
194
+ db.users.count()
195
+ ]);
196
+
197
+ return {
198
+ data,
199
+ pagination: {
200
+ total,
201
+ page,
202
+ pageSize,
203
+ totalPages: Math.ceil(total / pageSize)
204
+ }
205
+ };
206
+ }
207
+ ```
208
+
209
+ ## Filtering & Sorting
210
+
211
+ ```typescript
212
+ // ✅ Consistent query parameters
213
+ GET /orders?status=pending&status=processing // Multiple values
214
+ GET /orders?createdAt[gte]=2024-01-01 // Range filters
215
+ GET /orders?sort=-createdAt,+amount // Sort: - desc, + asc
216
+ GET /orders?fields=id,status,total // Sparse fieldsets
217
+
218
+ // Implementation
219
+ function parseFilters(query: Record<string, unknown>) {
220
+ const where: Record<string, unknown> = {};
221
+
222
+ if (query.status) {
223
+ where.status = Array.isArray(query.status)
224
+ ? { in: query.status }
225
+ : query.status;
226
+ }
227
+
228
+ if (query['createdAt[gte]']) {
229
+ where.createdAt = { gte: new Date(query['createdAt[gte]'] as string) };
230
+ }
231
+
232
+ return where;
233
+ }
234
+
235
+ function parseSort(sort?: string) {
236
+ if (!sort) return { createdAt: 'desc' };
237
+
238
+ return sort.split(',').reduce((acc, field) => {
239
+ const order = field.startsWith('-') ? 'desc' : 'asc';
240
+ const name = field.replace(/^[-+]/, '');
241
+ return { ...acc, [name]: order };
242
+ }, {});
243
+ }
244
+ ```
245
+
246
+ ## Authentication
247
+
248
+ ### JWT Pattern
249
+
250
+ ```typescript
251
+ // ✅ Middleware
252
+ async function authenticate(req: Request, res: Response, next: NextFunction) {
253
+ const header = req.headers.authorization;
254
+ if (!header?.startsWith('Bearer ')) {
255
+ throw UnauthorizedError();
256
+ }
257
+
258
+ const token = header.slice(7);
259
+ try {
260
+ const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
261
+ req.user = { id: payload.sub, roles: payload.roles };
262
+ next();
263
+ } catch {
264
+ throw UnauthorizedError();
265
+ }
266
+ }
267
+
268
+ // ✅ Apply to routes
269
+ app.use('/api', authenticate); // All /api routes require auth
270
+
271
+ // Or per-route
272
+ app.get('/users/:id', authenticate, getUser);
273
+ ```
274
+
275
+ ### API Keys
276
+
277
+ ```typescript
278
+ // ✅ For service-to-service or public APIs
279
+ async function authenticateApiKey(req: Request, res: Response, next: NextFunction) {
280
+ const apiKey = req.headers['x-api-key'];
281
+ if (!apiKey) {
282
+ throw UnauthorizedError();
283
+ }
284
+
285
+ // Hash the key to compare (never store plain keys)
286
+ const hashedKey = crypto.createHash('sha256').update(apiKey).digest('hex');
287
+ const keyRecord = await db.apiKeys.findUnique({ where: { hash: hashedKey } });
288
+
289
+ if (!keyRecord || keyRecord.revokedAt) {
290
+ throw UnauthorizedError();
291
+ }
292
+
293
+ req.apiKey = keyRecord;
294
+ next();
295
+ }
296
+ ```
297
+
298
+ ## Rate Limiting
299
+
300
+ ```typescript
301
+ // ✅ Return rate limit headers
302
+ import rateLimit from 'express-rate-limit';
303
+
304
+ const limiter = rateLimit({
305
+ windowMs: 60 * 1000, // 1 minute
306
+ max: 100, // 100 requests per window
307
+ standardHeaders: true, // Return RateLimit-* headers
308
+ legacyHeaders: false,
309
+ keyGenerator: (req) => req.user?.id || req.ip, // Per-user if authenticated
310
+ handler: (req, res) => {
311
+ res.status(429).json({
312
+ error: {
313
+ code: 'RATE_LIMITED',
314
+ message: 'Too many requests',
315
+ retryAfter: res.getHeader('Retry-After')
316
+ }
317
+ });
318
+ }
319
+ });
320
+
321
+ // Different limits for different endpoints
322
+ app.use('/api/', limiter);
323
+ app.use('/api/auth/', rateLimit({ windowMs: 60000, max: 5 })); // Stricter
324
+ ```
325
+
326
+ ## Versioning
327
+
328
+ ```typescript
329
+ // ✅ URL versioning (most explicit)
330
+ app.use('/v1', v1Router);
331
+ app.use('/v2', v2Router);
332
+
333
+ // Or header versioning
334
+ app.use((req, res, next) => {
335
+ req.apiVersion = req.headers['api-version'] || 'v1';
336
+ next();
337
+ });
338
+
339
+ // ✅ Deprecation headers
340
+ app.use('/v1', (req, res, next) => {
341
+ res.setHeader('Deprecation', 'true');
342
+ res.setHeader('Sunset', 'Sat, 01 Jan 2025 00:00:00 GMT');
343
+ res.setHeader('Link', '</v2>; rel="successor-version"');
344
+ next();
345
+ });
346
+ ```
347
+
348
+ ## Request Validation
349
+
350
+ ```typescript
351
+ // ✅ Use zod for runtime validation
352
+ import { z } from 'zod';
353
+
354
+ const CreateUserSchema = z.object({
355
+ email: z.string().email(),
356
+ name: z.string().min(1).max(100),
357
+ role: z.enum(['user', 'admin']).default('user')
358
+ });
359
+
360
+ const QuerySchema = z.object({
361
+ page: z.coerce.number().int().positive().default(1),
362
+ pageSize: z.coerce.number().int().min(1).max(100).default(20),
363
+ sort: z.string().optional()
364
+ });
365
+
366
+ // Middleware
367
+ function validate<T>(schema: z.Schema<T>, source: 'body' | 'query' | 'params') {
368
+ return (req: Request, res: Response, next: NextFunction) => {
369
+ const result = schema.safeParse(req[source]);
370
+ if (!result.success) {
371
+ throw ValidationError(
372
+ result.error.errors.reduce((acc, err) => ({
373
+ ...acc,
374
+ [err.path.join('.')]: err.message
375
+ }), {})
376
+ );
377
+ }
378
+ req[source] = result.data;
379
+ next();
380
+ };
381
+ }
382
+
383
+ // Usage
384
+ app.post('/users', validate(CreateUserSchema, 'body'), createUser);
385
+ app.get('/users', validate(QuerySchema, 'query'), listUsers);
386
+ ```
387
+
388
+ ## Response Formatting
389
+
390
+ ```typescript
391
+ // ✅ Consistent envelope (optional but helpful)
392
+ interface ApiResponse<T> {
393
+ data: T;
394
+ meta?: {
395
+ requestId: string;
396
+ timestamp: string;
397
+ };
398
+ }
399
+
400
+ // Middleware to wrap responses
401
+ app.use((req, res, next) => {
402
+ const originalJson = res.json.bind(res);
403
+ res.json = (body) => {
404
+ if (body?.error) return originalJson(body); // Don't wrap errors
405
+ return originalJson({
406
+ data: body,
407
+ meta: {
408
+ requestId: req.headers['x-request-id'] || crypto.randomUUID(),
409
+ timestamp: new Date().toISOString()
410
+ }
411
+ });
412
+ };
413
+ next();
414
+ });
415
+ ```
416
+
417
+ ## CORS
418
+
419
+ ```typescript
420
+ // ✅ Configure explicitly
421
+ import cors from 'cors';
422
+
423
+ app.use(cors({
424
+ origin: process.env.ALLOWED_ORIGINS?.split(',') || false,
425
+ methods: ['GET', 'POST', 'PATCH', 'DELETE'],
426
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
427
+ credentials: true,
428
+ maxAge: 86400 // Cache preflight for 24h
429
+ }));
430
+ ```
431
+
432
+ ## Documentation
433
+
434
+ ```typescript
435
+ // ✅ OpenAPI with zod-to-openapi
436
+ import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
437
+
438
+ const registry = new OpenAPIRegistry();
439
+
440
+ registry.registerPath({
441
+ method: 'post',
442
+ path: '/users',
443
+ summary: 'Create a user',
444
+ request: { body: { content: { 'application/json': { schema: CreateUserSchema } } } },
445
+ responses: {
446
+ 201: { description: 'User created', content: { 'application/json': { schema: UserSchema } } },
447
+ 400: { description: 'Validation error' },
448
+ 409: { description: 'Email already exists' }
449
+ }
450
+ });
451
+ ```
452
+
453
+ ## Idempotency
454
+
455
+ ```typescript
456
+ // ✅ For POST/PATCH that shouldn't be retried blindly
457
+ app.post('/payments', async (req, res) => {
458
+ const idempotencyKey = req.headers['idempotency-key'];
459
+ if (!idempotencyKey) {
460
+ throw ValidationError({ 'Idempotency-Key': 'Required header' });
461
+ }
462
+
463
+ // Check if we've seen this key
464
+ const existing = await redis.get(`idempotency:${idempotencyKey}`);
465
+ if (existing) {
466
+ return res.status(200).json(JSON.parse(existing));
467
+ }
468
+
469
+ // Process payment
470
+ const result = await processPayment(req.body);
471
+
472
+ // Store result for 24h
473
+ await redis.setex(`idempotency:${idempotencyKey}`, 86400, JSON.stringify(result));
474
+
475
+ return res.status(201).json(result);
476
+ });
477
+ ```
478
+
479
+ ## Health Checks
480
+
481
+ ```typescript
482
+ // ✅ Separate liveness and readiness
483
+ app.get('/health/live', (req, res) => {
484
+ res.status(200).json({ status: 'ok' });
485
+ });
486
+
487
+ app.get('/health/ready', async (req, res) => {
488
+ try {
489
+ await db.$queryRaw`SELECT 1`; // Check DB
490
+ await redis.ping(); // Check Redis
491
+ res.status(200).json({ status: 'ready' });
492
+ } catch (error) {
493
+ res.status(503).json({ status: 'not ready', error: error.message });
494
+ }
495
+ });
496
+ ```