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.
Files changed (50) hide show
  1. package/.claude/settings.local.json +16 -0
  2. package/LICENSE +674 -20
  3. package/README.md +728 -120
  4. package/RELATIONSHIPS.md +1013 -0
  5. package/TODO_rest-api-plugin-extras.js +142 -0
  6. package/connectors_ideas/express-plugin.js +120 -0
  7. package/connectors_ideas/fastify.js +219 -0
  8. package/connectors_ideas/http-plugin.js +138 -0
  9. package/connectors_ideas/koa-plugin.js +113 -0
  10. package/docs/fileUploads.md +722 -0
  11. package/docs/mini-guides/file-uploads-complete.md +159 -0
  12. package/examples/.gitkeep +0 -0
  13. package/examples/express-example.js +262 -0
  14. package/examples/express-plugin.js +366 -0
  15. package/examples/file-upload-example.js +205 -0
  16. package/examples/filename-strategies.js +140 -0
  17. package/examples/knex-basic.js +66 -0
  18. package/index.js +28 -202
  19. package/lib/payload-validators.js +685 -0
  20. package/lib/rest-api-errors.js +58 -0
  21. package/package.json +37 -20
  22. package/payloadValdators.js +686 -0
  23. package/plugins/core/connectors/express-plugin.js +449 -0
  24. package/plugins/core/connectors/http-plugin.js +450 -0
  25. package/plugins/core/file-handling-plugin.js +338 -0
  26. package/plugins/core/lib/busboy-detector.js +167 -0
  27. package/plugins/core/lib/formidable-detector.js +130 -0
  28. package/plugins/core/rest-api-knex-plugin.js +200 -0
  29. package/plugins/core/rest-api-plugin.js +1419 -0
  30. package/plugins/storage/local-storage.js +218 -0
  31. package/plugins/storage/s3-storage.js +125 -0
  32. package/test-examples/package-lock.json +906 -0
  33. package/test-examples/package.json +15 -0
  34. package/test-examples/test-basic-api.js +11 -0
  35. package/test-examples/test-books-resource.js +116 -0
  36. package/test-examples/test-complete-file-upload.js +140 -0
  37. package/test-examples/test-debug.js +28 -0
  38. package/test-examples/test-express-plugin.js +177 -0
  39. package/test-examples/test-express-simple.js +28 -0
  40. package/test-examples/test-express-with-import.js +71 -0
  41. package/test-examples/test-file-upload.js +132 -0
  42. package/test-examples/test-http-plugin.js +159 -0
  43. package/test-examples/test-programmatic-api.js +188 -0
  44. package/test-examples/test-rest-api-plugin.js +13 -0
  45. package/.jshintrc +0 -48
  46. package/.npmignore +0 -15
  47. package/examples/client.js +0 -18
  48. package/examples/client2.js +0 -27
  49. package/examples/server.js +0 -23
  50. 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
+ };