json-rest-api 0.1.1 → 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.
@@ -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
+ }