agentic-loop 3.10.3 → 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.
- package/.claude/commands/api.md +496 -0
- package/.claude/commands/aws.md +408 -0
- package/bin/ralph.sh +2 -1
- package/package.json +1 -1
- package/ralph/code-check.sh +307 -0
- package/ralph/loop.sh +80 -27
- package/ralph/prd-check.sh +498 -0
- package/ralph/utils.sh +66 -351
- package/templates/config/elixir.json +1 -1
- package/templates/config/fastmcp.json +1 -1
- package/templates/config/fullstack.json +1 -1
- package/templates/config/go.json +1 -1
- package/templates/config/minimal.json +1 -1
- package/templates/config/node.json +1 -1
- package/templates/config/python.json +1 -1
- package/templates/config/rust.json +1 -1
- package/ralph/verify.sh +0 -106
|
@@ -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
|
+
```
|