json-rest-api 0.1.0 → 1.0.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/README.md +392 -130
- package/TODO.todo +113 -0
- package/api.js +628 -0
- package/docs/API.md +1306 -0
- package/docs/FIXES_APPLIED.md +59 -0
- package/docs/GUIDES.md +1792 -0
- package/docs/RESOURCES_IN_EXPRESS.md +456 -0
- package/docs/example-versioning.js +168 -0
- package/docs/example.js +195 -0
- package/index.js +58 -207
- package/package.json +15 -25
- package/plugins/API guide.md +364 -0
- package/plugins/api-registry.js +142 -0
- package/plugins/api-versioning.md +135 -0
- package/plugins/http.js +273 -0
- package/plugins/logging.js +255 -0
- package/plugins/memory.js +182 -0
- package/plugins/mysql.js +753 -0
- package/plugins/openapi.js +401 -0
- package/plugins/positioning.js +220 -0
- package/plugins/security.js +261 -0
- package/plugins/validation.js +173 -0
- package/plugins/versioning.js +288 -0
- package/resource-helper.js +79 -0
- package/schema.js +481 -0
- package/test-basic.js +107 -0
- package/.jshintrc +0 -48
- package/.npmignore +0 -15
- package/LICENSE +0 -20
- package/examples/client.js +0 -18
- package/examples/client2.js +0 -27
- package/examples/server.js +0 -23
- package/test.js +0 -101
package/plugins/http.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HTTP plugin for JSON REST API with JSON:API compliance
|
|
5
|
+
*/
|
|
6
|
+
export const HTTPPlugin = {
|
|
7
|
+
install(api, options = {}) {
|
|
8
|
+
const router = express.Router();
|
|
9
|
+
api.router = router;
|
|
10
|
+
|
|
11
|
+
// Middleware for JSON parsing
|
|
12
|
+
router.use(express.json({
|
|
13
|
+
type: ['application/json', 'application/vnd.api+json']
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Base path for all routes
|
|
17
|
+
const basePath = options.basePath || '/api';
|
|
18
|
+
|
|
19
|
+
// Helper to parse JSON:API request body
|
|
20
|
+
const parseJsonApiBody = (body) => {
|
|
21
|
+
if (body.data) {
|
|
22
|
+
if (body.data.attributes) {
|
|
23
|
+
// JSON:API format
|
|
24
|
+
return { ...body.data.attributes, id: body.data.id };
|
|
25
|
+
}
|
|
26
|
+
// Assume data is the actual data
|
|
27
|
+
return body.data;
|
|
28
|
+
}
|
|
29
|
+
// Plain format
|
|
30
|
+
return body;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Helper to format JSON:API errors
|
|
34
|
+
const formatErrors = (errors, status = 400) => {
|
|
35
|
+
if (!Array.isArray(errors)) {
|
|
36
|
+
errors = [errors];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
errors: errors.map(err => {
|
|
41
|
+
if (err.status) return err; // Already formatted
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
status: String(status),
|
|
45
|
+
title: err.code || 'Error',
|
|
46
|
+
detail: err.message || String(err),
|
|
47
|
+
source: err.field ? { pointer: `/data/attributes/${err.field}` } : undefined
|
|
48
|
+
};
|
|
49
|
+
})
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Parse query parameters
|
|
54
|
+
const parseQueryParams = (req) => {
|
|
55
|
+
const params = {
|
|
56
|
+
filter: {},
|
|
57
|
+
sort: req.query.sort,
|
|
58
|
+
page: {
|
|
59
|
+
size: req.query['page[size]'] || req.query.pageSize,
|
|
60
|
+
number: req.query['page[number]'] || req.query.page
|
|
61
|
+
},
|
|
62
|
+
include: req.query.include,
|
|
63
|
+
fields: {}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Parse filters
|
|
67
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
68
|
+
if (key.startsWith('filter[') && key.endsWith(']')) {
|
|
69
|
+
const filterKey = key.slice(7, -1);
|
|
70
|
+
params.filter[filterKey] = value;
|
|
71
|
+
} else if (key.startsWith('fields[') && key.endsWith(']')) {
|
|
72
|
+
const fieldType = key.slice(7, -1);
|
|
73
|
+
params.fields[fieldType] = value.split(',');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Legacy filter support
|
|
78
|
+
if (Object.keys(params.filter).length === 0) {
|
|
79
|
+
// Copy non-standard query params as filters
|
|
80
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
81
|
+
if (!['sort', 'page', 'pageSize', 'include', 'fields'].includes(key) &&
|
|
82
|
+
!key.includes('[')) {
|
|
83
|
+
params.filter[key] = value;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return params;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Build links for pagination
|
|
92
|
+
const buildLinks = (req, params, meta) => {
|
|
93
|
+
const links = {};
|
|
94
|
+
const baseUrl = `${req.protocol}://${req.get('host')}${req.baseUrl}${req.path}`;
|
|
95
|
+
const queryParams = new URLSearchParams(req.query);
|
|
96
|
+
|
|
97
|
+
// Self link
|
|
98
|
+
links.self = `${baseUrl}?${queryParams}`;
|
|
99
|
+
|
|
100
|
+
if (meta.totalPages > 1) {
|
|
101
|
+
const currentPage = meta.pageNumber;
|
|
102
|
+
|
|
103
|
+
// First page
|
|
104
|
+
queryParams.set('page[number]', '1');
|
|
105
|
+
links.first = `${baseUrl}?${queryParams}`;
|
|
106
|
+
|
|
107
|
+
// Last page
|
|
108
|
+
queryParams.set('page[number]', String(meta.totalPages));
|
|
109
|
+
links.last = `${baseUrl}?${queryParams}`;
|
|
110
|
+
|
|
111
|
+
// Previous page
|
|
112
|
+
if (currentPage > 1) {
|
|
113
|
+
queryParams.set('page[number]', String(currentPage - 1));
|
|
114
|
+
links.prev = `${baseUrl}?${queryParams}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Next page
|
|
118
|
+
if (currentPage < meta.totalPages) {
|
|
119
|
+
queryParams.set('page[number]', String(currentPage + 1));
|
|
120
|
+
links.next = `${baseUrl}?${queryParams}`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return links;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Version-aware routing
|
|
128
|
+
if (api.options.name && api.options.version) {
|
|
129
|
+
// Add version info to all responses
|
|
130
|
+
router.use((req, res, next) => {
|
|
131
|
+
res.set('API-Version', api.options.version);
|
|
132
|
+
next();
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Routes
|
|
137
|
+
|
|
138
|
+
// GET collection
|
|
139
|
+
router.get('/:type', async (req, res) => {
|
|
140
|
+
try {
|
|
141
|
+
const params = parseQueryParams(req);
|
|
142
|
+
const result = await api.query(params, {
|
|
143
|
+
type: req.params.type,
|
|
144
|
+
...options.typeOptions?.[req.params.type]
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Add links
|
|
148
|
+
if (result.meta) {
|
|
149
|
+
result.links = buildLinks(req, params, result.meta);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
res.json(result);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const status = error.status || 400;
|
|
155
|
+
res.status(status).json(formatErrors(error.errors || error, status));
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// GET single resource
|
|
160
|
+
router.get('/:type/:id', async (req, res) => {
|
|
161
|
+
try {
|
|
162
|
+
const result = await api.get(req.params.id, {
|
|
163
|
+
type: req.params.type,
|
|
164
|
+
...options.typeOptions?.[req.params.type]
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (!result.data) {
|
|
168
|
+
res.status(404).json(formatErrors({
|
|
169
|
+
message: 'Resource not found',
|
|
170
|
+
code: 'NOT_FOUND'
|
|
171
|
+
}, 404));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
res.json(result);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const status = error.status || 400;
|
|
178
|
+
res.status(status).json(formatErrors(error.errors || error, status));
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// POST new resource
|
|
183
|
+
router.post('/:type', async (req, res) => {
|
|
184
|
+
try {
|
|
185
|
+
const data = parseJsonApiBody(req.body);
|
|
186
|
+
const result = await api.insert(data, {
|
|
187
|
+
type: req.params.type,
|
|
188
|
+
...options.typeOptions?.[req.params.type]
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
res.status(201)
|
|
192
|
+
.location(`${basePath}/${req.params.type}/${result.data.id}`)
|
|
193
|
+
.json(result);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const status = error.status || 400;
|
|
196
|
+
res.status(status).json(formatErrors(error.errors || error, status));
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// PATCH update resource
|
|
201
|
+
router.patch('/:type/:id', async (req, res) => {
|
|
202
|
+
try {
|
|
203
|
+
const data = parseJsonApiBody(req.body);
|
|
204
|
+
const result = await api.update(req.params.id, data, {
|
|
205
|
+
type: req.params.type,
|
|
206
|
+
...options.typeOptions?.[req.params.type]
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (!result.data) {
|
|
210
|
+
res.status(404).json(formatErrors({
|
|
211
|
+
message: 'Resource not found',
|
|
212
|
+
code: 'NOT_FOUND'
|
|
213
|
+
}, 404));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
res.json(result);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const status = error.status || 400;
|
|
220
|
+
res.status(status).json(formatErrors(error.errors || error, status));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// DELETE resource
|
|
225
|
+
router.delete('/:type/:id', async (req, res) => {
|
|
226
|
+
try {
|
|
227
|
+
await api.delete(req.params.id, {
|
|
228
|
+
type: req.params.type,
|
|
229
|
+
...options.typeOptions?.[req.params.type]
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
res.status(204).end();
|
|
233
|
+
} catch (error) {
|
|
234
|
+
const status = error.status || 400;
|
|
235
|
+
res.status(status).json(formatErrors(error.errors || error, status));
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// OPTIONS for CORS
|
|
240
|
+
router.options('*', (req, res) => {
|
|
241
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
|
|
242
|
+
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
243
|
+
res.status(204).end();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Mount router on Express app if provided
|
|
247
|
+
if (options.app) {
|
|
248
|
+
options.app.use(basePath, router);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Add method to manually mount router
|
|
252
|
+
api.mount = (app, path = basePath) => {
|
|
253
|
+
app.use(path, router);
|
|
254
|
+
return api;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Add middleware support
|
|
258
|
+
api.useMiddleware = (middleware) => {
|
|
259
|
+
router.use(middleware);
|
|
260
|
+
return api;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Add route-specific middleware
|
|
264
|
+
api.useRouteMiddleware = (method, path, ...middlewares) => {
|
|
265
|
+
// Store middleware to be applied when routes are defined
|
|
266
|
+
if (!api._routeMiddlewares) {
|
|
267
|
+
api._routeMiddlewares = [];
|
|
268
|
+
}
|
|
269
|
+
api._routeMiddlewares.push({ method, path, middlewares });
|
|
270
|
+
return api;
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging plugin implementing structured logging best practices
|
|
3
|
+
*/
|
|
4
|
+
export const LoggingPlugin = {
|
|
5
|
+
install(api, options = {}) {
|
|
6
|
+
const defaultOptions = {
|
|
7
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
8
|
+
format: 'json', // 'json' or 'pretty'
|
|
9
|
+
includeRequest: true,
|
|
10
|
+
includeResponse: true,
|
|
11
|
+
includeTiming: true,
|
|
12
|
+
sensitiveFields: ['password', 'token', 'secret', 'authorization'],
|
|
13
|
+
logger: console, // Can be replaced with winston, bunyan, etc.
|
|
14
|
+
...options
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const levels = {
|
|
18
|
+
error: 0,
|
|
19
|
+
warn: 1,
|
|
20
|
+
info: 2,
|
|
21
|
+
debug: 3
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Create structured logger
|
|
25
|
+
const log = (level, message, meta = {}) => {
|
|
26
|
+
if (levels[level] > levels[defaultOptions.level]) return;
|
|
27
|
+
|
|
28
|
+
const timestamp = new Date().toISOString();
|
|
29
|
+
const logEntry = {
|
|
30
|
+
timestamp,
|
|
31
|
+
level,
|
|
32
|
+
message,
|
|
33
|
+
...meta
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Remove sensitive data
|
|
37
|
+
const sanitized = sanitizeLogData(logEntry, defaultOptions.sensitiveFields);
|
|
38
|
+
|
|
39
|
+
if (defaultOptions.format === 'json') {
|
|
40
|
+
defaultOptions.logger[level](JSON.stringify(sanitized));
|
|
41
|
+
} else {
|
|
42
|
+
defaultOptions.logger[level](`[${timestamp}] ${level.toUpperCase()}: ${message}`, meta);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Add logging methods to API
|
|
47
|
+
api.log = {
|
|
48
|
+
error: (message, meta) => log('error', message, meta),
|
|
49
|
+
warn: (message, meta) => log('warn', message, meta),
|
|
50
|
+
info: (message, meta) => log('info', message, meta),
|
|
51
|
+
debug: (message, meta) => log('debug', message, meta)
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Log all operations
|
|
55
|
+
const timings = new WeakMap();
|
|
56
|
+
|
|
57
|
+
// Start timing
|
|
58
|
+
api.hook('beforeValidate', async (context) => {
|
|
59
|
+
if (defaultOptions.includeTiming) {
|
|
60
|
+
timings.set(context, Date.now());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (defaultOptions.includeRequest) {
|
|
64
|
+
api.log.info(`${context.method.toUpperCase()} ${context.options.type}`, {
|
|
65
|
+
operation: context.method,
|
|
66
|
+
type: context.options.type,
|
|
67
|
+
id: context.id,
|
|
68
|
+
requestId: context.options.requestId,
|
|
69
|
+
userId: context.options.userId,
|
|
70
|
+
data: context.method === 'query' ? context.params : context.data
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Log successful operations
|
|
76
|
+
api.hook('beforeSend', async (context) => {
|
|
77
|
+
const duration = defaultOptions.includeTiming && timings.has(context)
|
|
78
|
+
? Date.now() - timings.get(context)
|
|
79
|
+
: undefined;
|
|
80
|
+
|
|
81
|
+
if (defaultOptions.includeResponse) {
|
|
82
|
+
api.log.info(`${context.method.toUpperCase()} ${context.options.type} completed`, {
|
|
83
|
+
operation: context.method,
|
|
84
|
+
type: context.options.type,
|
|
85
|
+
id: context.id || context.result?.id,
|
|
86
|
+
requestId: context.options.requestId,
|
|
87
|
+
userId: context.options.userId,
|
|
88
|
+
duration,
|
|
89
|
+
resultCount: context.results?.length
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Clean up timing
|
|
94
|
+
if (timings.has(context)) {
|
|
95
|
+
timings.delete(context);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Log errors
|
|
100
|
+
api.hook('afterValidate', async (context) => {
|
|
101
|
+
if (context.errors.length > 0) {
|
|
102
|
+
api.log.warn('Validation errors', {
|
|
103
|
+
operation: context.method,
|
|
104
|
+
type: context.options.type,
|
|
105
|
+
errors: context.errors,
|
|
106
|
+
requestId: context.options.requestId,
|
|
107
|
+
userId: context.options.userId
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Log database operations
|
|
113
|
+
if (api.mysqlPools) {
|
|
114
|
+
// Wrap MySQL query method to add logging
|
|
115
|
+
const originalGetConnection = api.getConnection;
|
|
116
|
+
api.getConnection = (name) => {
|
|
117
|
+
const conn = originalGetConnection.call(api, name);
|
|
118
|
+
|
|
119
|
+
// Wrap the query method
|
|
120
|
+
const originalQuery = conn.pool.query.bind(conn.pool);
|
|
121
|
+
conn.pool.query = async (sql, params) => {
|
|
122
|
+
const start = Date.now();
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const result = await originalQuery(sql, params);
|
|
126
|
+
|
|
127
|
+
api.log.debug('SQL query executed', {
|
|
128
|
+
sql: sql.substring(0, 200), // Truncate long queries
|
|
129
|
+
duration: Date.now() - start,
|
|
130
|
+
rowCount: result[0]?.length
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return result;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
api.log.error('SQL query failed', {
|
|
136
|
+
sql: sql.substring(0, 200),
|
|
137
|
+
error: error.message,
|
|
138
|
+
duration: Date.now() - start
|
|
139
|
+
});
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return conn;
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// HTTP request logging
|
|
149
|
+
if (api.router) {
|
|
150
|
+
api.router.use((req, res, next) => {
|
|
151
|
+
const start = Date.now();
|
|
152
|
+
const requestId = req.headers['x-request-id'] || generateId();
|
|
153
|
+
|
|
154
|
+
// Add request ID to context
|
|
155
|
+
req.requestId = requestId;
|
|
156
|
+
|
|
157
|
+
// Log request
|
|
158
|
+
api.log.info('HTTP request', {
|
|
159
|
+
method: req.method,
|
|
160
|
+
url: req.url,
|
|
161
|
+
ip: req.ip,
|
|
162
|
+
userAgent: req.headers['user-agent'],
|
|
163
|
+
requestId
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Log response
|
|
167
|
+
const originalSend = res.send;
|
|
168
|
+
res.send = function(data) {
|
|
169
|
+
res.send = originalSend;
|
|
170
|
+
|
|
171
|
+
api.log.info('HTTP response', {
|
|
172
|
+
method: req.method,
|
|
173
|
+
url: req.url,
|
|
174
|
+
status: res.statusCode,
|
|
175
|
+
duration: Date.now() - start,
|
|
176
|
+
requestId
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return res.send(data);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
next();
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Audit logging for sensitive operations
|
|
187
|
+
api.hook('afterInsert', async (context) => {
|
|
188
|
+
if (options.auditLog) {
|
|
189
|
+
api.log.info('Resource created', {
|
|
190
|
+
audit: true,
|
|
191
|
+
operation: 'create',
|
|
192
|
+
type: context.options.type,
|
|
193
|
+
id: context.result?.id,
|
|
194
|
+
userId: context.options.userId,
|
|
195
|
+
changes: context.data
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
api.hook('afterUpdate', async (context) => {
|
|
201
|
+
if (options.auditLog) {
|
|
202
|
+
api.log.info('Resource updated', {
|
|
203
|
+
audit: true,
|
|
204
|
+
operation: 'update',
|
|
205
|
+
type: context.options.type,
|
|
206
|
+
id: context.id,
|
|
207
|
+
userId: context.options.userId,
|
|
208
|
+
changes: context.data
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
api.hook('afterDelete', async (context) => {
|
|
214
|
+
if (options.auditLog) {
|
|
215
|
+
api.log.info('Resource deleted', {
|
|
216
|
+
audit: true,
|
|
217
|
+
operation: 'delete',
|
|
218
|
+
type: context.options.type,
|
|
219
|
+
id: context.id,
|
|
220
|
+
userId: context.options.userId
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Helper to sanitize sensitive data
|
|
228
|
+
function sanitizeLogData(data, sensitiveFields) {
|
|
229
|
+
if (typeof data !== 'object' || data === null) return data;
|
|
230
|
+
|
|
231
|
+
const sanitized = Array.isArray(data) ? [] : {};
|
|
232
|
+
|
|
233
|
+
for (const [key, value] of Object.entries(data)) {
|
|
234
|
+
const lowerKey = key.toLowerCase();
|
|
235
|
+
|
|
236
|
+
// Check if field is sensitive
|
|
237
|
+
const isSensitive = sensitiveFields.some(field =>
|
|
238
|
+
lowerKey.includes(field.toLowerCase())
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
if (isSensitive) {
|
|
242
|
+
sanitized[key] = '[REDACTED]';
|
|
243
|
+
} else if (typeof value === 'object') {
|
|
244
|
+
sanitized[key] = sanitizeLogData(value, sensitiveFields);
|
|
245
|
+
} else {
|
|
246
|
+
sanitized[key] = value;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return sanitized;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function generateId() {
|
|
254
|
+
return Math.random().toString(36).substring(2, 15);
|
|
255
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import firstBy from 'thenby';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Memory storage plugin for JSON REST API
|
|
5
|
+
*/
|
|
6
|
+
export const MemoryPlugin = {
|
|
7
|
+
install(api, options = {}) {
|
|
8
|
+
// Initialize data storage
|
|
9
|
+
api.memoryData = options.initialData || [];
|
|
10
|
+
api.memoryIdCounter = options.initialIdCounter || 1;
|
|
11
|
+
|
|
12
|
+
// Implement CRUD operations
|
|
13
|
+
api.implement('get', async (context) => {
|
|
14
|
+
const { id, options } = context;
|
|
15
|
+
const idProperty = options.idProperty || api.options.idProperty;
|
|
16
|
+
|
|
17
|
+
const record = api.memoryData.find(item =>
|
|
18
|
+
String(item[idProperty]) === String(id)
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
return record || null;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
api.implement('query', async (context) => {
|
|
25
|
+
const { params, options } = context;
|
|
26
|
+
let data = [...api.memoryData];
|
|
27
|
+
|
|
28
|
+
// Apply filters
|
|
29
|
+
if (params.filter) {
|
|
30
|
+
data = data.filter(item => {
|
|
31
|
+
for (const [key, value] of Object.entries(params.filter)) {
|
|
32
|
+
if (item[key] !== value) return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Apply search (if search parameter exists)
|
|
39
|
+
if (params.search && options.searchFields) {
|
|
40
|
+
const searchLower = params.search.toLowerCase();
|
|
41
|
+
data = data.filter(item => {
|
|
42
|
+
return options.searchFields.some(field => {
|
|
43
|
+
const fieldValue = String(item[field] || '').toLowerCase();
|
|
44
|
+
return fieldValue.includes(searchLower);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Count total before pagination
|
|
50
|
+
const total = data.length;
|
|
51
|
+
|
|
52
|
+
// Apply sorting
|
|
53
|
+
if (params.sort) {
|
|
54
|
+
const sorts = params.sort.split(',');
|
|
55
|
+
let sortChain = firstBy(() => 0);
|
|
56
|
+
|
|
57
|
+
for (const sortField of sorts) {
|
|
58
|
+
const descending = sortField.startsWith('-');
|
|
59
|
+
const field = sortField.replace(/^-/, '');
|
|
60
|
+
|
|
61
|
+
sortChain = sortChain.thenBy(
|
|
62
|
+
item => item[field],
|
|
63
|
+
{ direction: descending ? -1 : 1, ignoreCase: true }
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
data.sort(sortChain);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Apply pagination
|
|
71
|
+
const pageSize = Number(params.page?.size) || 10;
|
|
72
|
+
const pageNumber = Number(params.page?.number) || 1;
|
|
73
|
+
const skip = (pageNumber - 1) * pageSize;
|
|
74
|
+
|
|
75
|
+
data = data.slice(skip, skip + pageSize);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
results: data,
|
|
79
|
+
meta: {
|
|
80
|
+
total,
|
|
81
|
+
pageSize,
|
|
82
|
+
pageNumber,
|
|
83
|
+
totalPages: Math.ceil(total / pageSize)
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
api.implement('insert', async (context) => {
|
|
89
|
+
const { data, options } = context;
|
|
90
|
+
const idProperty = options.idProperty || api.options.idProperty;
|
|
91
|
+
|
|
92
|
+
// Generate ID if not provided
|
|
93
|
+
if (!data[idProperty]) {
|
|
94
|
+
data[idProperty] = api.memoryIdCounter++;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Handle positioning if enabled
|
|
98
|
+
if (options.positionField && data[options.beforeIdField] !== undefined) {
|
|
99
|
+
await handlePositioning(api, data, options);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Add to memory
|
|
103
|
+
api.memoryData.push(data);
|
|
104
|
+
|
|
105
|
+
return { ...data };
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
api.implement('update', async (context) => {
|
|
109
|
+
const { id, data, options } = context;
|
|
110
|
+
const idProperty = options.idProperty || api.options.idProperty;
|
|
111
|
+
|
|
112
|
+
const index = api.memoryData.findIndex(item =>
|
|
113
|
+
String(item[idProperty]) === String(id)
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (index === -1) {
|
|
117
|
+
throw new Error('Resource not found');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Handle positioning
|
|
121
|
+
if (options.positionField && data[options.beforeIdField] !== undefined) {
|
|
122
|
+
await handlePositioning(api, { ...api.memoryData[index], ...data }, options);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Update record
|
|
126
|
+
api.memoryData[index] = { ...api.memoryData[index], ...data };
|
|
127
|
+
|
|
128
|
+
return api.memoryData[index];
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
api.implement('delete', async (context) => {
|
|
132
|
+
const { id, options } = context;
|
|
133
|
+
const idProperty = options.idProperty || api.options.idProperty;
|
|
134
|
+
|
|
135
|
+
const index = api.memoryData.findIndex(item =>
|
|
136
|
+
String(item[idProperty]) === String(id)
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (index === -1) {
|
|
140
|
+
throw new Error('Resource not found');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
api.memoryData.splice(index, 1);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Handle record positioning
|
|
150
|
+
*/
|
|
151
|
+
async function handlePositioning(api, record, options) {
|
|
152
|
+
const { positionField, beforeIdField } = options;
|
|
153
|
+
const idProperty = options.idProperty || api.options.idProperty;
|
|
154
|
+
const beforeId = record[beforeIdField];
|
|
155
|
+
|
|
156
|
+
if (beforeId === null) {
|
|
157
|
+
// Place at end
|
|
158
|
+
const maxPosition = Math.max(...api.memoryData.map(r => r[positionField] || 0));
|
|
159
|
+
record[positionField] = maxPosition + 1;
|
|
160
|
+
} else if (beforeId !== undefined) {
|
|
161
|
+
// Find the record to place before
|
|
162
|
+
const beforeRecord = api.memoryData.find(r =>
|
|
163
|
+
String(r[idProperty]) === String(beforeId)
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (beforeRecord) {
|
|
167
|
+
const position = beforeRecord[positionField] || 0;
|
|
168
|
+
|
|
169
|
+
// Shift positions of records after this position
|
|
170
|
+
api.memoryData.forEach(r => {
|
|
171
|
+
if (r[positionField] >= position) {
|
|
172
|
+
r[positionField]++;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
record[positionField] = position;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Remove beforeId from record as it's virtual
|
|
181
|
+
delete record[beforeIdField];
|
|
182
|
+
}
|