json-rest-api 0.1.1 → 1.0.1
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/settings.local.json +16 -0
- package/LICENSE +674 -20
- package/README.md +728 -120
- package/RELATIONSHIPS.md +1013 -0
- package/TODO_rest-api-plugin-extras.js +142 -0
- package/connectors_ideas/express-plugin.js +120 -0
- package/connectors_ideas/fastify.js +219 -0
- package/connectors_ideas/http-plugin.js +138 -0
- package/connectors_ideas/koa-plugin.js +113 -0
- package/docs/fileUploads.md +722 -0
- package/docs/mini-guides/file-uploads-complete.md +159 -0
- package/examples/.gitkeep +0 -0
- package/examples/express-example.js +262 -0
- package/examples/express-plugin.js +366 -0
- package/examples/file-upload-example.js +205 -0
- package/examples/filename-strategies.js +140 -0
- package/examples/knex-basic.js +66 -0
- package/index.js +28 -202
- package/lib/payload-validators.js +685 -0
- package/lib/rest-api-errors.js +58 -0
- package/package.json +37 -20
- package/payloadValdators.js +686 -0
- package/plugins/core/connectors/express-plugin.js +449 -0
- package/plugins/core/connectors/http-plugin.js +450 -0
- package/plugins/core/file-handling-plugin.js +338 -0
- package/plugins/core/lib/busboy-detector.js +167 -0
- package/plugins/core/lib/formidable-detector.js +130 -0
- package/plugins/core/rest-api-knex-plugin.js +200 -0
- package/plugins/core/rest-api-plugin.js +1419 -0
- package/plugins/storage/local-storage.js +218 -0
- package/plugins/storage/s3-storage.js +125 -0
- package/test-examples/package-lock.json +906 -0
- package/test-examples/package.json +15 -0
- package/test-examples/test-basic-api.js +11 -0
- package/test-examples/test-books-resource.js +116 -0
- package/test-examples/test-complete-file-upload.js +140 -0
- package/test-examples/test-debug.js +28 -0
- package/test-examples/test-express-plugin.js +177 -0
- package/test-examples/test-express-simple.js +28 -0
- package/test-examples/test-express-with-import.js +71 -0
- package/test-examples/test-file-upload.js +132 -0
- package/test-examples/test-http-plugin.js +159 -0
- package/test-examples/test-programmatic-api.js +188 -0
- package/test-examples/test-rest-api-plugin.js +13 -0
- package/.jshintrc +0 -48
- package/.npmignore +0 -15
- package/examples/client.js +0 -18
- package/examples/client2.js +0 -27
- package/examples/server.js +0 -23
- package/test.js +0 -101
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import { parse as parseUrl } from 'url';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HTTP Plugin for Hooked API
|
|
6
|
+
*
|
|
7
|
+
* This plugin creates a pure Node.js HTTP server for your REST API,
|
|
8
|
+
* without any framework dependencies like Express.
|
|
9
|
+
*
|
|
10
|
+
* Features:
|
|
11
|
+
* - Lightweight alternative to Express plugin
|
|
12
|
+
* - Automatic route creation for all scopes
|
|
13
|
+
* - JSON:API compliant request/response handling
|
|
14
|
+
* - Query parameter parsing with full qs support
|
|
15
|
+
* - Error mapping to HTTP status codes
|
|
16
|
+
* - Content type validation
|
|
17
|
+
* - File upload support with busboy or formidable
|
|
18
|
+
* - Character encoding support
|
|
19
|
+
* - Configurable request size limits
|
|
20
|
+
*
|
|
21
|
+
* Optional dependencies:
|
|
22
|
+
* - busboy or formidable: File upload support
|
|
23
|
+
*
|
|
24
|
+
* Basic usage:
|
|
25
|
+
* ```javascript
|
|
26
|
+
* import { HttpPlugin } from 'json-rest-api';
|
|
27
|
+
*
|
|
28
|
+
* const api = new Api({ name: 'my-api', version: '1.0.0' });
|
|
29
|
+
* api.use(RestApiPlugin);
|
|
30
|
+
* api.use(HttpPlugin, {
|
|
31
|
+
* port: 3000, // Default: 3000
|
|
32
|
+
* basePath: '/api', // Default: '/api'
|
|
33
|
+
* strictContentType: true, // Default: true
|
|
34
|
+
* requestSizeLimit: '10mb' // Default: '1mb'
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
37
|
+
* // Start the server on the configured port
|
|
38
|
+
* api.http.startServer();
|
|
39
|
+
*
|
|
40
|
+
* // Or start on a different port
|
|
41
|
+
* api.http.startServer(4000);
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* Advanced usage:
|
|
45
|
+
* ```javascript
|
|
46
|
+
* // Access the raw HTTP server for custom configuration
|
|
47
|
+
* const server = api.http.server;
|
|
48
|
+
* server.timeout = 60000; // 60 second timeout
|
|
49
|
+
*
|
|
50
|
+
* // Use the request handler with your own server
|
|
51
|
+
* import { createServer } from 'https';
|
|
52
|
+
* const httpsServer = createServer(sslOptions, api.http.handler);
|
|
53
|
+
* httpsServer.listen(443);
|
|
54
|
+
*
|
|
55
|
+
* // Or integrate into an existing HTTP server
|
|
56
|
+
* myExistingServer.on('request', (req, res) => {
|
|
57
|
+
* if (req.url.startsWith('/api')) {
|
|
58
|
+
* api.http.handler(req, res);
|
|
59
|
+
* } else {
|
|
60
|
+
* // Handle other routes
|
|
61
|
+
* }
|
|
62
|
+
* });
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* File upload configuration:
|
|
66
|
+
* ```javascript
|
|
67
|
+
* api.use(HttpPlugin, {
|
|
68
|
+
* enableFileUploads: true, // Default: true
|
|
69
|
+
* fileParser: 'busboy', // 'busboy' or 'formidable'
|
|
70
|
+
* fileParserOptions: {
|
|
71
|
+
* limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
|
72
|
+
* }
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
// Import qs for query string parsing
|
|
78
|
+
import qs from 'qs';
|
|
79
|
+
|
|
80
|
+
export const HttpPlugin = {
|
|
81
|
+
name: 'http',
|
|
82
|
+
dependencies: ['rest-api', 'file-handling'],
|
|
83
|
+
|
|
84
|
+
async install({ on, vars, helpers, pluginOptions, log, scopes, api }) {
|
|
85
|
+
// Initialize http namespace
|
|
86
|
+
api.http = {};
|
|
87
|
+
|
|
88
|
+
const httpOptions = pluginOptions.http || {};
|
|
89
|
+
const basePath = httpOptions.basePath || '/api';
|
|
90
|
+
const strictContentType = httpOptions.strictContentType !== false;
|
|
91
|
+
const port = httpOptions.port || 3000;
|
|
92
|
+
const requestSizeLimit = httpOptions.requestSizeLimit || '1mb';
|
|
93
|
+
|
|
94
|
+
// Simple body parsing
|
|
95
|
+
const getRawBody = async (req, options = {}) => {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
let body = '';
|
|
98
|
+
req.on('data', chunk => {
|
|
99
|
+
body += chunk.toString();
|
|
100
|
+
if (options.limit && body.length > options.limit) {
|
|
101
|
+
reject(new Error('Request body too large'));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
req.on('end', () => resolve(body));
|
|
105
|
+
req.on('error', reject);
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Simple content type parsing
|
|
110
|
+
const parseContentType = (req) => {
|
|
111
|
+
const header = req.headers['content-type'] || 'application/json';
|
|
112
|
+
const [type, ...params] = header.split(';');
|
|
113
|
+
const parameters = {};
|
|
114
|
+
params.forEach(param => {
|
|
115
|
+
const [key, value] = param.trim().split('=');
|
|
116
|
+
if (key && value) {
|
|
117
|
+
parameters[key] = value.replace(/^["']|["']$/g, '');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
return { type: type.trim(), parameters };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Register file detector if file handling is enabled
|
|
124
|
+
if (httpOptions.enableFileUploads !== false && api.rest.registerFileDetector) {
|
|
125
|
+
const parserLib = httpOptions.fileParser || 'busboy';
|
|
126
|
+
const parserOptions = httpOptions.fileParserOptions || {};
|
|
127
|
+
|
|
128
|
+
let detector;
|
|
129
|
+
|
|
130
|
+
if (parserLib === 'busboy') {
|
|
131
|
+
try {
|
|
132
|
+
const { createBusboyDetector } = await import('../lib/busboy-detector.js');
|
|
133
|
+
detector = createBusboyDetector(parserOptions);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
log.warn('Busboy not installed. Install with: npm install busboy');
|
|
136
|
+
}
|
|
137
|
+
} else if (parserLib === 'formidable') {
|
|
138
|
+
try {
|
|
139
|
+
const { createFormidableDetector } = await import('../lib/formidable-detector.js');
|
|
140
|
+
detector = createFormidableDetector(parserOptions);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
log.warn('Formidable not installed. Install with: npm install formidable');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (detector) {
|
|
147
|
+
api.rest.registerFileDetector({
|
|
148
|
+
name: `http-${detector.name}`,
|
|
149
|
+
detect: (params) => {
|
|
150
|
+
if (!params._httpReq) return false;
|
|
151
|
+
return detector.detect(params);
|
|
152
|
+
},
|
|
153
|
+
parse: detector.parse
|
|
154
|
+
});
|
|
155
|
+
log.info(`HTTP plugin registered file detector: ${detector.name}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Parse query parameters from URL
|
|
161
|
+
*/
|
|
162
|
+
const parseQueryParams = (url) => {
|
|
163
|
+
const queryString = url.split('?')[1] || '';
|
|
164
|
+
const rawParams = qs.parse(queryString);
|
|
165
|
+
const queryParams = {};
|
|
166
|
+
|
|
167
|
+
// Transform to expected format
|
|
168
|
+
if (rawParams.include) {
|
|
169
|
+
queryParams.include = rawParams.include.split(',').map(s => s.trim());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (rawParams.fields) {
|
|
173
|
+
queryParams.fields = rawParams.fields;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (rawParams.filter) {
|
|
177
|
+
queryParams.filter = rawParams.filter;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (rawParams.sort) {
|
|
181
|
+
queryParams.sort = rawParams.sort.split(',').map(s => s.trim());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (rawParams.page) {
|
|
185
|
+
queryParams.page = rawParams.page;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return queryParams;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Map errors to HTTP responses
|
|
193
|
+
*/
|
|
194
|
+
const handleError = (error, res) => {
|
|
195
|
+
let status = 500;
|
|
196
|
+
let errorResponse = {
|
|
197
|
+
errors: [{
|
|
198
|
+
status: '500',
|
|
199
|
+
title: 'Internal Server Error',
|
|
200
|
+
detail: error.message
|
|
201
|
+
}]
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Map error codes to HTTP status
|
|
205
|
+
if (error.code) {
|
|
206
|
+
switch (error.code) {
|
|
207
|
+
case 'REST_API_VALIDATION':
|
|
208
|
+
status = 422;
|
|
209
|
+
errorResponse.errors = [{
|
|
210
|
+
status: '422',
|
|
211
|
+
title: 'Validation Error',
|
|
212
|
+
detail: error.message,
|
|
213
|
+
source: error.details
|
|
214
|
+
}];
|
|
215
|
+
if (error.violations) {
|
|
216
|
+
errorResponse.errors = error.violations.map(v => ({
|
|
217
|
+
status: '422',
|
|
218
|
+
title: 'Validation Error',
|
|
219
|
+
detail: v.message,
|
|
220
|
+
source: { pointer: v.field }
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
break;
|
|
224
|
+
|
|
225
|
+
case 'REST_API_RESOURCE':
|
|
226
|
+
// Map subtype to status
|
|
227
|
+
switch (error.subtype) {
|
|
228
|
+
case 'not_found':
|
|
229
|
+
status = 404;
|
|
230
|
+
errorResponse.errors[0].status = '404';
|
|
231
|
+
errorResponse.errors[0].title = 'Not Found';
|
|
232
|
+
break;
|
|
233
|
+
case 'conflict':
|
|
234
|
+
status = 409;
|
|
235
|
+
errorResponse.errors[0].status = '409';
|
|
236
|
+
errorResponse.errors[0].title = 'Conflict';
|
|
237
|
+
break;
|
|
238
|
+
case 'forbidden':
|
|
239
|
+
status = 403;
|
|
240
|
+
errorResponse.errors[0].status = '403';
|
|
241
|
+
errorResponse.errors[0].title = 'Forbidden';
|
|
242
|
+
break;
|
|
243
|
+
default:
|
|
244
|
+
status = 400;
|
|
245
|
+
errorResponse.errors[0].status = '400';
|
|
246
|
+
errorResponse.errors[0].title = 'Bad Request';
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
249
|
+
|
|
250
|
+
case 'REST_API_PAYLOAD':
|
|
251
|
+
status = 400;
|
|
252
|
+
errorResponse.errors = [{
|
|
253
|
+
status: '400',
|
|
254
|
+
title: 'Bad Request',
|
|
255
|
+
detail: error.message,
|
|
256
|
+
source: { pointer: error.path }
|
|
257
|
+
}];
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
res.writeHead(status, { 'Content-Type': 'application/vnd.api+json' });
|
|
263
|
+
res.end(JSON.stringify(errorResponse));
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Main request handler
|
|
268
|
+
*/
|
|
269
|
+
const handleRequest = async (req, res) => {
|
|
270
|
+
const { pathname } = parseUrl(req.url);
|
|
271
|
+
|
|
272
|
+
// Check if path starts with basePath
|
|
273
|
+
if (!pathname.startsWith(basePath)) {
|
|
274
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
275
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Parse the path
|
|
280
|
+
const pathParts = pathname.slice(basePath.length).split('/').filter(Boolean);
|
|
281
|
+
if (pathParts.length === 0 || pathParts.length > 2) {
|
|
282
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
283
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const [scopeName, id] = pathParts;
|
|
288
|
+
|
|
289
|
+
// Check if scope exists
|
|
290
|
+
if (!scopes[scopeName]) {
|
|
291
|
+
handleError({
|
|
292
|
+
code: 'REST_API_RESOURCE',
|
|
293
|
+
subtype: 'not_found',
|
|
294
|
+
message: `Scope '${scopeName}' not found`
|
|
295
|
+
}, res);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Validate content type for requests with body
|
|
300
|
+
if (['POST', 'PUT', 'PATCH'].includes(req.method) && strictContentType) {
|
|
301
|
+
const ct = req.headers['content-type'];
|
|
302
|
+
if (ct && !ct.includes('application/vnd.api+json') && !ct.includes('application/json')) {
|
|
303
|
+
res.writeHead(415, { 'Content-Type': 'application/json' });
|
|
304
|
+
res.end(JSON.stringify({
|
|
305
|
+
errors: [{
|
|
306
|
+
status: '415',
|
|
307
|
+
title: 'Unsupported Media Type',
|
|
308
|
+
detail: 'Content-Type must be application/vnd.api+json or application/json'
|
|
309
|
+
}]
|
|
310
|
+
}));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
// Determine method based on HTTP method and path
|
|
317
|
+
let methodName;
|
|
318
|
+
const params = {};
|
|
319
|
+
|
|
320
|
+
switch (req.method) {
|
|
321
|
+
case 'GET':
|
|
322
|
+
if (id) {
|
|
323
|
+
methodName = 'get';
|
|
324
|
+
params.id = id;
|
|
325
|
+
} else {
|
|
326
|
+
methodName = 'query';
|
|
327
|
+
}
|
|
328
|
+
params.queryParams = parseQueryParams(req.url);
|
|
329
|
+
break;
|
|
330
|
+
|
|
331
|
+
case 'POST':
|
|
332
|
+
if (id) {
|
|
333
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
334
|
+
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
methodName = 'post';
|
|
338
|
+
params.queryParams = parseQueryParams(req.url);
|
|
339
|
+
break;
|
|
340
|
+
|
|
341
|
+
case 'PUT':
|
|
342
|
+
if (!id) {
|
|
343
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
344
|
+
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
methodName = 'put';
|
|
348
|
+
params.id = id;
|
|
349
|
+
params.queryParams = parseQueryParams(req.url);
|
|
350
|
+
break;
|
|
351
|
+
|
|
352
|
+
case 'PATCH':
|
|
353
|
+
if (!id) {
|
|
354
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
355
|
+
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
methodName = 'patch';
|
|
359
|
+
params.id = id;
|
|
360
|
+
params.queryParams = parseQueryParams(req.url);
|
|
361
|
+
break;
|
|
362
|
+
|
|
363
|
+
case 'DELETE':
|
|
364
|
+
if (!id) {
|
|
365
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
366
|
+
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
methodName = 'delete';
|
|
370
|
+
params.id = id;
|
|
371
|
+
break;
|
|
372
|
+
|
|
373
|
+
default:
|
|
374
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
375
|
+
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Parse body for mutations
|
|
380
|
+
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
|
|
381
|
+
const ct = parseContentType(req);
|
|
382
|
+
const rawBody = await getRawBody(req, {
|
|
383
|
+
length: req.headers['content-length'],
|
|
384
|
+
limit: requestSizeLimit,
|
|
385
|
+
encoding: ct.parameters.charset || 'utf-8'
|
|
386
|
+
});
|
|
387
|
+
params.inputRecord = JSON.parse(rawBody);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Store HTTP request/response in params for plugins that need it
|
|
391
|
+
params._httpReq = req;
|
|
392
|
+
params._httpRes = res;
|
|
393
|
+
|
|
394
|
+
log.debug(`HTTP ${req.method} ${pathname}`, { scopeName, methodName, params });
|
|
395
|
+
|
|
396
|
+
// Call the API method
|
|
397
|
+
const scope = scopes[scopeName];
|
|
398
|
+
const result = await scope[methodName](params);
|
|
399
|
+
|
|
400
|
+
// Send response
|
|
401
|
+
res.setHeader('Content-Type', 'application/vnd.api+json');
|
|
402
|
+
|
|
403
|
+
switch (methodName) {
|
|
404
|
+
case 'post':
|
|
405
|
+
res.writeHead(201);
|
|
406
|
+
res.end(JSON.stringify(result));
|
|
407
|
+
break;
|
|
408
|
+
case 'delete':
|
|
409
|
+
res.writeHead(204);
|
|
410
|
+
res.end();
|
|
411
|
+
break;
|
|
412
|
+
default:
|
|
413
|
+
res.writeHead(200);
|
|
414
|
+
res.end(JSON.stringify(result));
|
|
415
|
+
}
|
|
416
|
+
} catch (error) {
|
|
417
|
+
handleError(error, res);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// Create HTTP server
|
|
422
|
+
const server = createServer(handleRequest);
|
|
423
|
+
|
|
424
|
+
// Store server and handler in http namespace
|
|
425
|
+
api.http.server = server;
|
|
426
|
+
api.http.handler = handleRequest;
|
|
427
|
+
|
|
428
|
+
// Add convenient start method
|
|
429
|
+
api.http.startServer = (customPort) => {
|
|
430
|
+
const finalPort = customPort || port;
|
|
431
|
+
server.listen(finalPort, () => {
|
|
432
|
+
log.info(`HTTP server listening on port ${finalPort}`);
|
|
433
|
+
log.info(`API available at http://localhost:${finalPort}${basePath}`);
|
|
434
|
+
});
|
|
435
|
+
return server;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// Listen for scope additions (for future dynamic scopes)
|
|
439
|
+
on('scope:added', 'logHttpRoute', ({ eventData }) => {
|
|
440
|
+
log.info(`HTTP routes available for scope '${eventData.scopeName}' at ${basePath}/${eventData.scopeName}`);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Log existing scopes
|
|
444
|
+
for (const scopeName of Object.keys(scopes)) {
|
|
445
|
+
log.info(`HTTP routes available for scope '${scopeName}' at ${basePath}/${scopeName}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
log.info('HTTP plugin initialized successfully');
|
|
449
|
+
}
|
|
450
|
+
};
|