json-rest-api 1.0.0 → 1.0.2
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 -0
- package/README.md +655 -309
- 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 +31 -56
- package/lib/payload-validators.js +685 -0
- package/lib/rest-api-errors.js +58 -0
- package/package.json +41 -14
- 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/TODO.todo +0 -113
- package/api.js +0 -628
- package/docs/API.md +0 -1306
- package/docs/FIXES_APPLIED.md +0 -59
- package/docs/GUIDES.md +0 -1792
- package/docs/RESOURCES_IN_EXPRESS.md +0 -456
- package/docs/example-versioning.js +0 -168
- package/docs/example.js +0 -195
- package/plugins/API guide.md +0 -364
- package/plugins/api-registry.js +0 -142
- package/plugins/api-versioning.md +0 -135
- package/plugins/http.js +0 -273
- package/plugins/logging.js +0 -255
- package/plugins/memory.js +0 -182
- package/plugins/mysql.js +0 -753
- package/plugins/openapi.js +0 -401
- package/plugins/positioning.js +0 -220
- package/plugins/security.js +0 -261
- package/plugins/validation.js +0 -173
- package/plugins/versioning.js +0 -288
- package/resource-helper.js +0 -79
- package/schema.js +0 -481
- package/test-basic.js +0 -107
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Express Plugin for Hooked API
|
|
5
|
+
*
|
|
6
|
+
* This plugin creates HTTP endpoints for your REST API by translating
|
|
7
|
+
* Express HTTP requests into hooked-api method calls.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Automatic route creation for all scopes (including dynamically added ones)
|
|
11
|
+
* - JSON:API compliant request/response handling
|
|
12
|
+
* - Query parameter parsing (include, fields, filter, sort, page)
|
|
13
|
+
* - Error mapping to HTTP status codes
|
|
14
|
+
* - Content type validation
|
|
15
|
+
* - Middleware injection points
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* ```javascript
|
|
19
|
+
* import { ExpressPlugin } from './express-plugin.js';
|
|
20
|
+
*
|
|
21
|
+
* const api = new Api({ name: 'my-api', version: '1.0.0' });
|
|
22
|
+
* api.use(RestApiPlugin);
|
|
23
|
+
* api.use(ExpressPlugin, {
|
|
24
|
+
* basePath: '/api', // Default: '/api'
|
|
25
|
+
* strictContentType: true, // Default: true
|
|
26
|
+
* requestSizeLimit: '10mb' // Default: '1mb'
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* // In your Express app:
|
|
30
|
+
* app.use(api.express.router);
|
|
31
|
+
* // or
|
|
32
|
+
* api.express.mount(app, '/v1');
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export const ExpressPlugin = {
|
|
37
|
+
name: 'express',
|
|
38
|
+
dependencies: ['rest-api'],
|
|
39
|
+
|
|
40
|
+
install({ on, vars, helpers, pluginOptions, log, scopes, api }) {
|
|
41
|
+
const expressOptions = pluginOptions.express || {};
|
|
42
|
+
const basePath = expressOptions.basePath || '/api';
|
|
43
|
+
const strictContentType = expressOptions.strictContentType !== false;
|
|
44
|
+
const requestSizeLimit = expressOptions.requestSizeLimit || '1mb';
|
|
45
|
+
|
|
46
|
+
// Track which scopes have routes created
|
|
47
|
+
const routesCreated = new Set();
|
|
48
|
+
|
|
49
|
+
// Create Express router
|
|
50
|
+
const router = expressOptions.router || express.Router();
|
|
51
|
+
|
|
52
|
+
// Add body parsing middleware
|
|
53
|
+
router.use(express.json({
|
|
54
|
+
limit: requestSizeLimit,
|
|
55
|
+
type: ['application/json', 'application/vnd.api+json']
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
// Content type validation middleware
|
|
59
|
+
if (strictContentType) {
|
|
60
|
+
router.use((req, res, next) => {
|
|
61
|
+
// Only validate for requests with bodies
|
|
62
|
+
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
|
|
63
|
+
const contentType = req.get('Content-Type');
|
|
64
|
+
|
|
65
|
+
if (contentType && !contentType.includes('application/vnd.api+json') && !contentType.includes('application/json')) {
|
|
66
|
+
return res.status(415).json({
|
|
67
|
+
errors: [{
|
|
68
|
+
status: '415',
|
|
69
|
+
title: 'Unsupported Media Type',
|
|
70
|
+
detail: `Content-Type must be application/vnd.api+json or application/json`
|
|
71
|
+
}]
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
next();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Query parameter parser
|
|
81
|
+
* Handles both standard notation (filter.status) and bracket notation (filter[status])
|
|
82
|
+
*/
|
|
83
|
+
const parseQueryParams = (req) => {
|
|
84
|
+
const query = req.query;
|
|
85
|
+
const queryParams = {};
|
|
86
|
+
|
|
87
|
+
// Parse include (comma-separated string to array)
|
|
88
|
+
if (query.include) {
|
|
89
|
+
queryParams.include = query.include.split(',').map(s => s.trim());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Parse fields (nested object)
|
|
93
|
+
queryParams.fields = {};
|
|
94
|
+
|
|
95
|
+
// Parse filter (object)
|
|
96
|
+
queryParams.filter = {};
|
|
97
|
+
|
|
98
|
+
// Parse page (object)
|
|
99
|
+
queryParams.page = {};
|
|
100
|
+
|
|
101
|
+
// Process all query parameters
|
|
102
|
+
for (const key in query) {
|
|
103
|
+
const value = query[key];
|
|
104
|
+
|
|
105
|
+
// Handle bracket notation: fields[articles]=title,body
|
|
106
|
+
let match;
|
|
107
|
+
if ((match = key.match(/^fields\[(.+)\]$/))) {
|
|
108
|
+
queryParams.fields[match[1]] = value;
|
|
109
|
+
} else if ((match = key.match(/^filter\[(.+)\]$/))) {
|
|
110
|
+
queryParams.filter[match[1]] = value;
|
|
111
|
+
} else if ((match = key.match(/^page\[(.+)\]$/))) {
|
|
112
|
+
queryParams.page[match[1]] = value;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Handle object notation if express query parser is configured
|
|
117
|
+
if (typeof query.fields === 'object' && !Array.isArray(query.fields)) {
|
|
118
|
+
Object.assign(queryParams.fields, query.fields);
|
|
119
|
+
}
|
|
120
|
+
if (typeof query.filter === 'object' && !Array.isArray(query.filter)) {
|
|
121
|
+
Object.assign(queryParams.filter, query.filter);
|
|
122
|
+
}
|
|
123
|
+
if (typeof query.page === 'object' && !Array.isArray(query.page)) {
|
|
124
|
+
Object.assign(queryParams.page, query.page);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Parse sort (comma-separated string to array)
|
|
128
|
+
if (query.sort) {
|
|
129
|
+
queryParams.sort = query.sort.split(',').map(s => s.trim());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Clean up empty objects
|
|
133
|
+
if (Object.keys(queryParams.fields).length === 0) delete queryParams.fields;
|
|
134
|
+
if (Object.keys(queryParams.filter).length === 0) delete queryParams.filter;
|
|
135
|
+
if (Object.keys(queryParams.page).length === 0) delete queryParams.page;
|
|
136
|
+
|
|
137
|
+
return queryParams;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Error handler - maps API errors to HTTP responses
|
|
142
|
+
*/
|
|
143
|
+
const handleError = (error, res) => {
|
|
144
|
+
let status = 500;
|
|
145
|
+
let errorResponse = {
|
|
146
|
+
errors: [{
|
|
147
|
+
status: '500',
|
|
148
|
+
title: 'Internal Server Error',
|
|
149
|
+
detail: error.message
|
|
150
|
+
}]
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Check for RestApiError types by code
|
|
154
|
+
if (error.code) {
|
|
155
|
+
switch (error.code) {
|
|
156
|
+
case 'REST_API_VALIDATION':
|
|
157
|
+
status = 422;
|
|
158
|
+
errorResponse.errors = [{
|
|
159
|
+
status: '422',
|
|
160
|
+
title: 'Validation Error',
|
|
161
|
+
detail: error.message,
|
|
162
|
+
source: error.details
|
|
163
|
+
}];
|
|
164
|
+
if (error.violations) {
|
|
165
|
+
errorResponse.errors = error.violations.map(v => ({
|
|
166
|
+
status: '422',
|
|
167
|
+
title: 'Validation Error',
|
|
168
|
+
detail: v.message,
|
|
169
|
+
source: { pointer: v.field }
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
|
|
174
|
+
case 'REST_API_RESOURCE':
|
|
175
|
+
// Map subtype to status
|
|
176
|
+
switch (error.subtype) {
|
|
177
|
+
case 'not_found':
|
|
178
|
+
status = 404;
|
|
179
|
+
errorResponse.errors[0].status = '404';
|
|
180
|
+
errorResponse.errors[0].title = 'Not Found';
|
|
181
|
+
break;
|
|
182
|
+
case 'conflict':
|
|
183
|
+
status = 409;
|
|
184
|
+
errorResponse.errors[0].status = '409';
|
|
185
|
+
errorResponse.errors[0].title = 'Conflict';
|
|
186
|
+
break;
|
|
187
|
+
case 'forbidden':
|
|
188
|
+
status = 403;
|
|
189
|
+
errorResponse.errors[0].status = '403';
|
|
190
|
+
errorResponse.errors[0].title = 'Forbidden';
|
|
191
|
+
break;
|
|
192
|
+
default:
|
|
193
|
+
status = 400;
|
|
194
|
+
errorResponse.errors[0].status = '400';
|
|
195
|
+
errorResponse.errors[0].title = 'Bad Request';
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
|
|
199
|
+
case 'REST_API_PAYLOAD':
|
|
200
|
+
status = 400;
|
|
201
|
+
errorResponse.errors = [{
|
|
202
|
+
status: '400',
|
|
203
|
+
title: 'Bad Request',
|
|
204
|
+
detail: error.message,
|
|
205
|
+
source: { pointer: error.path }
|
|
206
|
+
}];
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
res.status(status).json(errorResponse);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Creates request handler for a specific HTTP method
|
|
216
|
+
*/
|
|
217
|
+
const createRequestHandler = (scopeName, methodName) => {
|
|
218
|
+
return async (req, res) => {
|
|
219
|
+
try {
|
|
220
|
+
const params = {};
|
|
221
|
+
|
|
222
|
+
// Add ID parameter for single-resource operations
|
|
223
|
+
if (['get', 'put', 'patch', 'delete'].includes(methodName)) {
|
|
224
|
+
params.id = req.params.id;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Add query parameters
|
|
228
|
+
if (['query', 'get'].includes(methodName)) {
|
|
229
|
+
params.queryParams = parseQueryParams(req);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Add body for mutations
|
|
233
|
+
if (['post', 'put', 'patch'].includes(methodName)) {
|
|
234
|
+
params.inputRecord = req.body;
|
|
235
|
+
|
|
236
|
+
// For PUT and PATCH, also parse query params
|
|
237
|
+
if (['put', 'patch'].includes(methodName)) {
|
|
238
|
+
params.queryParams = parseQueryParams(req);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Store Express request/response in params for plugins that need it
|
|
243
|
+
params._expressReq = req;
|
|
244
|
+
params._expressRes = res;
|
|
245
|
+
|
|
246
|
+
log.debug(`HTTP ${req.method} ${req.path}`, { scopeName, methodName, params });
|
|
247
|
+
|
|
248
|
+
// Call the API method
|
|
249
|
+
const scope = scopes[scopeName];
|
|
250
|
+
if (!scope) {
|
|
251
|
+
return handleError({
|
|
252
|
+
code: 'REST_API_RESOURCE',
|
|
253
|
+
subtype: 'not_found',
|
|
254
|
+
message: `Scope '${scopeName}' not found`
|
|
255
|
+
}, res);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const result = await scope[methodName](params);
|
|
259
|
+
|
|
260
|
+
// Send response
|
|
261
|
+
res.set('Content-Type', 'application/vnd.api+json');
|
|
262
|
+
|
|
263
|
+
switch (methodName) {
|
|
264
|
+
case 'post':
|
|
265
|
+
res.status(201).json(result);
|
|
266
|
+
break;
|
|
267
|
+
case 'delete':
|
|
268
|
+
res.sendStatus(204);
|
|
269
|
+
break;
|
|
270
|
+
default:
|
|
271
|
+
res.json(result);
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
handleError(error, res);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Creates routes for a scope
|
|
281
|
+
*/
|
|
282
|
+
const createRoutesForScope = (scopeName) => {
|
|
283
|
+
if (routesCreated.has(scopeName)) {
|
|
284
|
+
log.trace(`Routes already created for scope '${scopeName}'`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const scopePath = `${basePath}/${scopeName}`;
|
|
289
|
+
|
|
290
|
+
log.info(`Creating Express routes for scope '${scopeName}' at ${scopePath}`);
|
|
291
|
+
|
|
292
|
+
// Apply any before middleware
|
|
293
|
+
const beforeMiddleware = expressOptions.middleware?.beforeScope?.[scopeName] || [];
|
|
294
|
+
|
|
295
|
+
// GET /api/{scope} - Query collection
|
|
296
|
+
router.get(scopePath, ...beforeMiddleware, createRequestHandler(scopeName, 'query'));
|
|
297
|
+
|
|
298
|
+
// GET /api/{scope}/{id} - Get single resource
|
|
299
|
+
router.get(`${scopePath}/:id`, ...beforeMiddleware, createRequestHandler(scopeName, 'get'));
|
|
300
|
+
|
|
301
|
+
// POST /api/{scope} - Create resource
|
|
302
|
+
router.post(scopePath, ...beforeMiddleware, createRequestHandler(scopeName, 'post'));
|
|
303
|
+
|
|
304
|
+
// PUT /api/{scope}/{id} - Replace resource
|
|
305
|
+
router.put(`${scopePath}/:id`, ...beforeMiddleware, createRequestHandler(scopeName, 'put'));
|
|
306
|
+
|
|
307
|
+
// PATCH /api/{scope}/{id} - Update resource
|
|
308
|
+
router.patch(`${scopePath}/:id`, ...beforeMiddleware, createRequestHandler(scopeName, 'patch'));
|
|
309
|
+
|
|
310
|
+
// DELETE /api/{scope}/{id} - Delete resource
|
|
311
|
+
router.delete(`${scopePath}/:id`, ...beforeMiddleware, createRequestHandler(scopeName, 'delete'));
|
|
312
|
+
|
|
313
|
+
// Apply any after middleware
|
|
314
|
+
const afterMiddleware = expressOptions.middleware?.afterScope?.[scopeName] || [];
|
|
315
|
+
if (afterMiddleware.length > 0) {
|
|
316
|
+
router.use(scopePath, ...afterMiddleware);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
routesCreated.add(scopeName);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// Listen for scope additions
|
|
323
|
+
on('scope:added', 'createExpressRoutes', ({ eventData }) => {
|
|
324
|
+
createRoutesForScope(eventData.scopeName);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Create routes for any existing scopes
|
|
328
|
+
for (const scopeName of Object.keys(scopes)) {
|
|
329
|
+
createRoutesForScope(scopeName);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 404 handler for unmatched routes within the API
|
|
333
|
+
router.use((req, res) => {
|
|
334
|
+
res.status(404).json({
|
|
335
|
+
errors: [{
|
|
336
|
+
status: '404',
|
|
337
|
+
title: 'Not Found',
|
|
338
|
+
detail: `The requested endpoint ${req.method} ${req.path} does not exist`
|
|
339
|
+
}]
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Apply global middleware if configured
|
|
344
|
+
let finalRouter = router;
|
|
345
|
+
if (expressOptions.middleware?.beforeAll) {
|
|
346
|
+
const globalRouter = express.Router();
|
|
347
|
+
globalRouter.use(...expressOptions.middleware.beforeAll);
|
|
348
|
+
globalRouter.use(router);
|
|
349
|
+
finalRouter = globalRouter;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Initialize express namespace
|
|
353
|
+
api.express = {};
|
|
354
|
+
|
|
355
|
+
// Store router in express namespace
|
|
356
|
+
api.express.router = finalRouter;
|
|
357
|
+
|
|
358
|
+
// Add convenient mounting method
|
|
359
|
+
api.express.mount = (app, path = '') => {
|
|
360
|
+
app.use(path, finalRouter);
|
|
361
|
+
log.info(`Express routes mounted at ${path || '/'}`);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
log.info('Express plugin initialized successfully');
|
|
365
|
+
}
|
|
366
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Upload Example
|
|
3
|
+
*
|
|
4
|
+
* This example demonstrates how to use the file handling plugin
|
|
5
|
+
* with various storage backends and protocols.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Api } from 'hooked-api';
|
|
9
|
+
import {
|
|
10
|
+
RestApiPlugin,
|
|
11
|
+
FileHandlingPlugin,
|
|
12
|
+
ExpressPlugin,
|
|
13
|
+
HttpPlugin
|
|
14
|
+
} from '../index.js';
|
|
15
|
+
import { LocalStorage } from '../plugins/storage/local-storage.js';
|
|
16
|
+
import { S3Storage } from '../plugins/storage/s3-storage.js';
|
|
17
|
+
import express from 'express';
|
|
18
|
+
|
|
19
|
+
// Create API instance
|
|
20
|
+
const api = new Api({
|
|
21
|
+
name: 'file-upload-example',
|
|
22
|
+
version: '1.0.0'
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Configure storage backends
|
|
26
|
+
const localStorage = new LocalStorage({
|
|
27
|
+
directory: './uploads/images',
|
|
28
|
+
baseUrl: 'http://localhost:3000/uploads/images'
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const s3Storage = new S3Storage({
|
|
32
|
+
bucket: 'my-app-uploads',
|
|
33
|
+
region: 'us-east-1',
|
|
34
|
+
prefix: 'user-uploads/',
|
|
35
|
+
mockMode: true // Using mock mode for this example
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Use plugins - order matters!
|
|
39
|
+
api.use(RestApiPlugin);
|
|
40
|
+
api.use(FileHandlingPlugin); // Must come after RestApiPlugin
|
|
41
|
+
api.use(ExpressPlugin, {
|
|
42
|
+
fileParser: 'busboy', // or 'formidable'
|
|
43
|
+
fileParserOptions: {
|
|
44
|
+
limits: {
|
|
45
|
+
fileSize: 10 * 1024 * 1024, // 10MB
|
|
46
|
+
files: 5 // Max 5 files per request
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Define schemas with file fields
|
|
52
|
+
api.addScope('users', {
|
|
53
|
+
schema: {
|
|
54
|
+
name: { type: 'string', required: true },
|
|
55
|
+
email: { type: 'string', required: true },
|
|
56
|
+
avatar: {
|
|
57
|
+
type: 'file',
|
|
58
|
+
storage: localStorage,
|
|
59
|
+
accepts: ['image/jpeg', 'image/png', 'image/gif'],
|
|
60
|
+
maxSize: '5mb',
|
|
61
|
+
required: false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
api.addScope('documents', {
|
|
67
|
+
schema: {
|
|
68
|
+
title: { type: 'string', required: true },
|
|
69
|
+
description: { type: 'string' },
|
|
70
|
+
file: {
|
|
71
|
+
type: 'file',
|
|
72
|
+
storage: s3Storage,
|
|
73
|
+
accepts: ['application/pdf', 'application/msword', 'text/plain'],
|
|
74
|
+
maxSize: '50mb',
|
|
75
|
+
required: true
|
|
76
|
+
},
|
|
77
|
+
thumbnail: {
|
|
78
|
+
type: 'file',
|
|
79
|
+
storage: localStorage,
|
|
80
|
+
accepts: ['image/*'],
|
|
81
|
+
maxSize: '2mb',
|
|
82
|
+
required: false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Add storage helpers for demonstration
|
|
88
|
+
api.helpers.dataPost = async ({ scopeName, inputRecord }) => {
|
|
89
|
+
console.log(`Creating ${scopeName}:`, inputRecord);
|
|
90
|
+
|
|
91
|
+
// Simulate database save
|
|
92
|
+
const id = Math.random().toString(36).substring(7);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
data: {
|
|
96
|
+
type: scopeName,
|
|
97
|
+
id: id,
|
|
98
|
+
attributes: inputRecord.data.attributes
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
api.helpers.dataGet = async ({ scopeName, id }) => {
|
|
104
|
+
// Simulate database fetch
|
|
105
|
+
return {
|
|
106
|
+
data: {
|
|
107
|
+
type: scopeName,
|
|
108
|
+
id: id,
|
|
109
|
+
attributes: {
|
|
110
|
+
// Would normally fetch from database
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Create Express app
|
|
117
|
+
const app = express();
|
|
118
|
+
|
|
119
|
+
// Serve uploaded files statically
|
|
120
|
+
app.use('/uploads', express.static('./uploads'));
|
|
121
|
+
|
|
122
|
+
// Mount API
|
|
123
|
+
api.express.mount(app, '/api');
|
|
124
|
+
|
|
125
|
+
// Add a simple upload form for testing
|
|
126
|
+
app.get('/', (req, res) => {
|
|
127
|
+
res.send(`
|
|
128
|
+
<!DOCTYPE html>
|
|
129
|
+
<html>
|
|
130
|
+
<head>
|
|
131
|
+
<title>File Upload Example</title>
|
|
132
|
+
</head>
|
|
133
|
+
<body>
|
|
134
|
+
<h1>File Upload Example</h1>
|
|
135
|
+
|
|
136
|
+
<h2>Create User with Avatar</h2>
|
|
137
|
+
<form action="/api/users" method="POST" enctype="multipart/form-data">
|
|
138
|
+
<div>
|
|
139
|
+
<label>Name: <input name="name" required></label>
|
|
140
|
+
</div>
|
|
141
|
+
<div>
|
|
142
|
+
<label>Email: <input name="email" type="email" required></label>
|
|
143
|
+
</div>
|
|
144
|
+
<div>
|
|
145
|
+
<label>Avatar: <input name="avatar" type="file" accept="image/*"></label>
|
|
146
|
+
</div>
|
|
147
|
+
<button type="submit">Create User</button>
|
|
148
|
+
</form>
|
|
149
|
+
|
|
150
|
+
<h2>Upload Document</h2>
|
|
151
|
+
<form action="/api/documents" method="POST" enctype="multipart/form-data">
|
|
152
|
+
<div>
|
|
153
|
+
<label>Title: <input name="title" required></label>
|
|
154
|
+
</div>
|
|
155
|
+
<div>
|
|
156
|
+
<label>Description: <textarea name="description"></textarea></label>
|
|
157
|
+
</div>
|
|
158
|
+
<div>
|
|
159
|
+
<label>Document: <input name="file" type="file" accept=".pdf,.doc,.txt" required></label>
|
|
160
|
+
</div>
|
|
161
|
+
<div>
|
|
162
|
+
<label>Thumbnail: <input name="thumbnail" type="file" accept="image/*"></label>
|
|
163
|
+
</div>
|
|
164
|
+
<button type="submit">Upload Document</button>
|
|
165
|
+
</form>
|
|
166
|
+
|
|
167
|
+
<hr>
|
|
168
|
+
<p>Note: The form data will be automatically converted to JSON:API format by the file handling plugin.</p>
|
|
169
|
+
</body>
|
|
170
|
+
</html>
|
|
171
|
+
`);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Start server
|
|
175
|
+
const PORT = process.env.PORT || 3000;
|
|
176
|
+
app.listen(PORT, () => {
|
|
177
|
+
console.log(`File upload example running at http://localhost:${PORT}`);
|
|
178
|
+
console.log(`API endpoints:`);
|
|
179
|
+
console.log(` POST http://localhost:${PORT}/api/users (with multipart form data)`);
|
|
180
|
+
console.log(` POST http://localhost:${PORT}/api/documents (with multipart form data)`);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Example cURL commands:
|
|
184
|
+
/*
|
|
185
|
+
# Upload user with avatar
|
|
186
|
+
curl -X POST http://localhost:3000/api/users \
|
|
187
|
+
-F "name=John Doe" \
|
|
188
|
+
-F "email=john@example.com" \
|
|
189
|
+
-F "avatar=@/path/to/avatar.jpg"
|
|
190
|
+
|
|
191
|
+
# Upload document with thumbnail
|
|
192
|
+
curl -X POST http://localhost:3000/api/documents \
|
|
193
|
+
-F "title=Important Document" \
|
|
194
|
+
-F "description=This is a very important document" \
|
|
195
|
+
-F "file=@/path/to/document.pdf" \
|
|
196
|
+
-F "thumbnail=@/path/to/thumbnail.png"
|
|
197
|
+
|
|
198
|
+
# The file handling plugin will:
|
|
199
|
+
# 1. Detect the multipart upload
|
|
200
|
+
# 2. Parse the files
|
|
201
|
+
# 3. Validate file types and sizes
|
|
202
|
+
# 4. Upload to configured storage
|
|
203
|
+
# 5. Replace file fields with URLs
|
|
204
|
+
# 6. Convert to JSON:API format
|
|
205
|
+
*/
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filename Strategy Examples
|
|
3
|
+
*
|
|
4
|
+
* Different approaches to handling uploaded filenames
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { LocalStorage } from '../plugins/storage/local-storage.js';
|
|
8
|
+
|
|
9
|
+
// Strategy 1: Random hash (most secure, default)
|
|
10
|
+
const hashStorage = new LocalStorage({
|
|
11
|
+
directory: './uploads/images',
|
|
12
|
+
baseUrl: '/uploads/images',
|
|
13
|
+
nameStrategy: 'hash',
|
|
14
|
+
preserveExtension: true
|
|
15
|
+
});
|
|
16
|
+
// Uploads as: "a7f8d9e2b4c6e1f3.jpg"
|
|
17
|
+
|
|
18
|
+
// Strategy 2: Timestamp-based (sortable)
|
|
19
|
+
const timestampStorage = new LocalStorage({
|
|
20
|
+
directory: './uploads/documents',
|
|
21
|
+
baseUrl: '/uploads/documents',
|
|
22
|
+
nameStrategy: 'timestamp',
|
|
23
|
+
preserveExtension: true
|
|
24
|
+
});
|
|
25
|
+
// Uploads as: "1672531200000_a8f9.pdf"
|
|
26
|
+
|
|
27
|
+
// Strategy 3: Sanitized original (user-friendly)
|
|
28
|
+
const originalStorage = new LocalStorage({
|
|
29
|
+
directory: './uploads/user-files',
|
|
30
|
+
baseUrl: '/uploads/user-files',
|
|
31
|
+
nameStrategy: 'original',
|
|
32
|
+
preserveExtension: true,
|
|
33
|
+
maxFilenameLength: 100
|
|
34
|
+
});
|
|
35
|
+
// "My Resume 2024!.pdf" uploads as: "My_Resume_2024_.pdf"
|
|
36
|
+
// Duplicates become: "My_Resume_2024_1.pdf", "My_Resume_2024_2.pdf"
|
|
37
|
+
|
|
38
|
+
// Strategy 4: Custom naming function
|
|
39
|
+
const customStorage = new LocalStorage({
|
|
40
|
+
directory: './uploads/profiles',
|
|
41
|
+
baseUrl: '/uploads/profiles',
|
|
42
|
+
nameStrategy: 'custom',
|
|
43
|
+
nameGenerator: async (file) => {
|
|
44
|
+
// Example: user ID + timestamp
|
|
45
|
+
const userId = file.userId || 'anonymous';
|
|
46
|
+
const timestamp = Date.now();
|
|
47
|
+
return `user_${userId}_${timestamp}`;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
// Uploads as: "user_12345_1672531200000.jpg"
|
|
51
|
+
|
|
52
|
+
// Strategy 5: High security (no extensions)
|
|
53
|
+
const secureStorage = new LocalStorage({
|
|
54
|
+
directory: './uploads/secure',
|
|
55
|
+
baseUrl: '/uploads/secure',
|
|
56
|
+
nameStrategy: 'hash',
|
|
57
|
+
preserveExtension: false // Store as .bin
|
|
58
|
+
});
|
|
59
|
+
// All files upload as: "a7f8d9e2b4c6e1f3.bin"
|
|
60
|
+
|
|
61
|
+
// Strategy 6: Whitelist extensions
|
|
62
|
+
const whitelistStorage = new LocalStorage({
|
|
63
|
+
directory: './uploads/images',
|
|
64
|
+
baseUrl: '/uploads/images',
|
|
65
|
+
nameStrategy: 'hash',
|
|
66
|
+
preserveExtension: true,
|
|
67
|
+
allowedExtensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
|
68
|
+
});
|
|
69
|
+
// Only allows specific image extensions
|
|
70
|
+
|
|
71
|
+
// Example: Organization by date
|
|
72
|
+
const dateOrganizedStorage = new LocalStorage({
|
|
73
|
+
directory: './uploads',
|
|
74
|
+
baseUrl: '/uploads',
|
|
75
|
+
nameStrategy: 'custom',
|
|
76
|
+
nameGenerator: async (file) => {
|
|
77
|
+
const date = new Date();
|
|
78
|
+
const year = date.getFullYear();
|
|
79
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
80
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
81
|
+
const random = crypto.randomBytes(8).toString('hex');
|
|
82
|
+
|
|
83
|
+
// Create date-based subdirectory
|
|
84
|
+
const dateDir = `${year}/${month}/${day}`;
|
|
85
|
+
await fs.mkdir(path.join('./uploads', dateDir), { recursive: true });
|
|
86
|
+
|
|
87
|
+
return `${dateDir}/${random}`;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
// Uploads as: "2024/01/15/a7f8d9e2b4c6e1f3.jpg"
|
|
91
|
+
|
|
92
|
+
// Example: Content-based naming
|
|
93
|
+
const contentStorage = new LocalStorage({
|
|
94
|
+
directory: './uploads/media',
|
|
95
|
+
baseUrl: '/uploads/media',
|
|
96
|
+
nameStrategy: 'custom',
|
|
97
|
+
nameGenerator: async (file) => {
|
|
98
|
+
// Generate hash of file content for deduplication
|
|
99
|
+
const crypto = require('crypto');
|
|
100
|
+
const hash = crypto.createHash('sha256');
|
|
101
|
+
hash.update(file.data);
|
|
102
|
+
const contentHash = hash.digest('hex').substring(0, 16);
|
|
103
|
+
|
|
104
|
+
// Prefix by type
|
|
105
|
+
const typePrefix = file.mimetype.startsWith('image/') ? 'img' :
|
|
106
|
+
file.mimetype.startsWith('video/') ? 'vid' :
|
|
107
|
+
file.mimetype.startsWith('audio/') ? 'aud' : 'doc';
|
|
108
|
+
|
|
109
|
+
return `${typePrefix}_${contentHash}`;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
// Uploads as: "img_a7f8d9e2b4c6e1f3.jpg" (same file always gets same name)
|
|
113
|
+
|
|
114
|
+
// Example usage in schema
|
|
115
|
+
api.addScope('products', {
|
|
116
|
+
schema: {
|
|
117
|
+
name: { type: 'string', required: true },
|
|
118
|
+
|
|
119
|
+
// Product images need user-friendly names
|
|
120
|
+
mainImage: {
|
|
121
|
+
type: 'file',
|
|
122
|
+
storage: originalStorage,
|
|
123
|
+
accepts: ['image/*']
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// Technical documents use timestamp
|
|
127
|
+
manual: {
|
|
128
|
+
type: 'file',
|
|
129
|
+
storage: timestampStorage,
|
|
130
|
+
accepts: ['application/pdf']
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
// User uploads use high security
|
|
134
|
+
attachment: {
|
|
135
|
+
type: 'file',
|
|
136
|
+
storage: secureStorage,
|
|
137
|
+
accepts: ['*']
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|