vanta-api 1.1.0 → 1.1.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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/api-features.js +107 -64
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanta-api",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Advanced API features and security configuration for Node.js/MongoDB.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -9,7 +9,7 @@ const logger = winston.createLogger({
9
9
  winston.format.timestamp(),
10
10
  winston.format.json()
11
11
  ),
12
- transports: [new winston.transports.Console()]
12
+ transports: [new winston.transports.Console()],
13
13
  });
14
14
 
15
15
  export class ApiFeatures {
@@ -55,7 +55,7 @@ export class ApiFeatures {
55
55
  if (this.query.fields) {
56
56
  const allowedFields = this.query.fields
57
57
  .split(",")
58
- .filter(f => !securityConfig.forbiddenFields.includes(f))
58
+ .filter((f) => !securityConfig.forbiddenFields.includes(f))
59
59
  .reduce((acc, curr) => ({ ...acc, [curr]: 1 }), {});
60
60
 
61
61
  this.pipeline.push({ $project: allowedFields });
@@ -64,25 +64,21 @@ export class ApiFeatures {
64
64
  }
65
65
 
66
66
  paginate() {
67
- const { maxLimit } = securityConfig.accessLevels[this.userRole] || { maxLimit: 100 };
67
+ const { maxLimit } = securityConfig.accessLevels[this.userRole] || {
68
+ maxLimit: 100,
69
+ };
68
70
  const page = Math.max(parseInt(this.query.page, 10) || 1, 1);
69
- const limit = Math.min(
70
- parseInt(this.query.limit, 10) || 10,
71
- maxLimit
72
- );
73
-
74
- this.pipeline.push(
75
- { $skip: (page - 1) * limit },
76
- { $limit: limit }
77
- );
71
+ const limit = Math.min(parseInt(this.query.limit, 10) || 10, maxLimit);
72
+
73
+ this.pipeline.push({ $skip: (page - 1) * limit }, { $limit: limit });
78
74
  return this;
79
75
  }
80
76
 
81
77
  populate(input = "") {
82
78
  let populateOptions = [];
83
-
79
+
84
80
  if (Array.isArray(input)) {
85
- input.forEach(item => {
81
+ input.forEach((item) => {
86
82
  if (typeof item === "object" && item.path) {
87
83
  populateOptions.push(item);
88
84
  } else if (typeof item === "string") {
@@ -92,19 +88,25 @@ export class ApiFeatures {
92
88
  } else if (typeof input === "object" && input.path) {
93
89
  populateOptions.push(input);
94
90
  } else if (typeof input === "string" && input.trim().length > 0) {
95
- input.split(",").filter(Boolean).forEach(item => {
96
- populateOptions.push(item.trim());
97
- });
91
+ input
92
+ .split(",")
93
+ .filter(Boolean)
94
+ .forEach((item) => {
95
+ populateOptions.push(item.trim());
96
+ });
98
97
  }
99
-
98
+
100
99
  if (this.query.populate) {
101
- this.query.populate.split(",").filter(Boolean).forEach(item => {
102
- populateOptions.push(item.trim());
103
- });
100
+ this.query.populate
101
+ .split(",")
102
+ .filter(Boolean)
103
+ .forEach((item) => {
104
+ populateOptions.push(item.trim());
105
+ });
104
106
  }
105
-
107
+
106
108
  const uniqueMap = new Map();
107
- populateOptions.forEach(item => {
109
+ populateOptions.forEach((item) => {
108
110
  if (typeof item === "object" && item.path) {
109
111
  uniqueMap.set(item.path, item);
110
112
  } else if (typeof item === "string") {
@@ -112,23 +114,24 @@ export class ApiFeatures {
112
114
  }
113
115
  });
114
116
  const uniquePopulateOptions = Array.from(uniqueMap.values());
115
-
116
- uniquePopulateOptions.forEach(option => {
117
- let field, projection = {};
117
+
118
+ uniquePopulateOptions.forEach((option) => {
119
+ let field,
120
+ projection = {};
118
121
  if (typeof option === "object") {
119
122
  field = option.path;
120
123
  if (option.select) {
121
- option.select.split(" ").forEach(fieldName => {
124
+ option.select.split(" ").forEach((fieldName) => {
122
125
  if (fieldName) projection[fieldName.trim()] = 1;
123
126
  });
124
127
  }
125
128
  } else if (typeof option === "string") {
126
129
  field = option;
127
130
  }
128
-
131
+
129
132
  field = field.trim();
130
133
  const { collection, isArray } = this.#getCollectionInfo(field);
131
-
134
+
132
135
  let lookupStage = {};
133
136
  if (Object.keys(projection).length > 0) {
134
137
  lookupStage = {
@@ -138,13 +141,13 @@ export class ApiFeatures {
138
141
  pipeline: [
139
142
  {
140
143
  $match: {
141
- $expr: { $eq: ["$_id", "$$localId"] }
142
- }
144
+ $expr: { $eq: ["$_id", "$$localId"] },
145
+ },
143
146
  },
144
- { $project: projection }
147
+ { $project: projection },
145
148
  ],
146
- as: field
147
- }
149
+ as: field,
150
+ },
148
151
  };
149
152
  } else {
150
153
  lookupStage = {
@@ -152,23 +155,23 @@ export class ApiFeatures {
152
155
  from: collection,
153
156
  localField: field,
154
157
  foreignField: "_id",
155
- as: field
156
- }
158
+ as: field,
159
+ },
157
160
  };
158
161
  }
159
-
162
+
160
163
  this.pipeline.push(lookupStage);
161
164
  this.pipeline.push({
162
165
  $unwind: {
163
166
  path: `$${field}`,
164
- preserveNullAndEmptyArrays: true
165
- }
167
+ preserveNullAndEmptyArrays: true,
168
+ },
166
169
  });
167
170
  });
168
-
171
+
169
172
  return this;
170
173
  }
171
-
174
+
172
175
  addManualFilters(filters) {
173
176
  if (filters) {
174
177
  this.manualFilters = { ...this.manualFilters, ...filters };
@@ -183,12 +186,13 @@ export class ApiFeatures {
183
186
  }
184
187
  const [countResult, dataResult] = await Promise.all([
185
188
  this.Model.aggregate([...this.countPipeline, { $count: "total" }]),
186
- (this.useCursor
187
- ? this.Model.aggregate(this.pipeline).cursor({ batchSize: 100 }).exec()
189
+ this.useCursor
190
+ ? this.Model.aggregate(this.pipeline)
191
+ .cursor({ batchSize: 100 })
192
+ .exec()
188
193
  : this.Model.aggregate(this.pipeline)
189
194
  .allowDiskUse(options.allowDiskUse || false)
190
- .readConcern("majority")
191
- )
195
+ .readConcern("majority"),
192
196
  ]);
193
197
 
194
198
  const count = countResult[0]?.total || 0;
@@ -201,11 +205,11 @@ export class ApiFeatures {
201
205
  } else {
202
206
  data = dataResult;
203
207
  }
204
-
208
+
205
209
  return {
206
210
  success: true,
207
211
  count,
208
- data
212
+ data,
209
213
  };
210
214
  } catch (error) {
211
215
  this.#handleError(error);
@@ -214,11 +218,11 @@ export class ApiFeatures {
214
218
 
215
219
  // ---------- Security and Sanitization Methods ----------
216
220
  #initialSanitization() {
217
- ["$where", "$accumulator", "$function"].forEach(op => {
221
+ ["$where", "$accumulator", "$function"].forEach((op) => {
218
222
  delete this.query[op];
219
223
  delete this.manualFilters[op];
220
224
  });
221
- ["page", "limit"].forEach(field => {
225
+ ["page", "limit"].forEach((field) => {
222
226
  if (this.query[field] && !/^\d+$/.test(this.query[field])) {
223
227
  throw new HandleERROR(`Invalid value for ${field}`, 400);
224
228
  }
@@ -227,39 +231,50 @@ export class ApiFeatures {
227
231
 
228
232
  #parseQueryFilters() {
229
233
  const queryObj = { ...this.query };
230
- ["page", "limit", "sort", "fields", "populate"].forEach(el => delete queryObj[el]);
234
+ ["page", "limit", "sort", "fields", "populate"].forEach(
235
+ (el) => delete queryObj[el]
236
+ );
231
237
 
232
238
  return JSON.parse(
233
- JSON.stringify(queryObj)
234
- .replace(/\b(gte|gt|lte|lt|in|nin|eq|ne|regex|exists|size)\b/g, "$$$&")
239
+ JSON.stringify(queryObj).replace(
240
+ /\b(gte|gt|lte|lt|in|nin|eq|ne|regex|exists|size)\b/g,
241
+ "$$$&"
242
+ )
235
243
  );
236
244
  }
237
245
 
238
246
  #applySecurityFilters(filters) {
239
247
  let result = { ...filters };
240
-
241
- securityConfig.forbiddenFields.forEach(field => delete result[field]);
242
-
248
+
249
+ securityConfig.forbiddenFields.forEach((field) => delete result[field]);
250
+
243
251
  if (this.userRole !== "admin" && this.Model.schema.path("isActive")) {
244
252
  result.isActive = true;
245
253
  result = this.#sanitizeNestedObjects(result);
246
254
  }
247
-
255
+
248
256
  return result;
249
257
  }
250
258
 
251
259
  #sanitizeNestedObjects(obj) {
252
260
  return Object.entries(obj).reduce((acc, [key, value]) => {
253
261
  // Handle ObjectId fields with nested operators
254
- if (key.endsWith("Id") && typeof value === "object" && !Array.isArray(value)) {
262
+ if (
263
+ key.endsWith("Id") &&
264
+ typeof value === "object" &&
265
+ !Array.isArray(value)
266
+ ) {
255
267
  const sanitizedObj = {};
256
268
  for (const [op, val] of Object.entries(value)) {
257
- if (["$eq", "$ne", "$gt", "$gte", "$lt", "$lte"].includes(op) && mongoose.isValidObjectId(val)) {
269
+ if (
270
+ ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte"].includes(op) &&
271
+ mongoose.isValidObjectId(val)
272
+ ) {
258
273
  sanitizedObj[op] = new mongoose.Types.ObjectId(val);
259
274
  } else if (["$in", "$nin"].includes(op) && Array.isArray(val)) {
260
275
  sanitizedObj[op] = val
261
- .filter(v => mongoose.isValidObjectId(v))
262
- .map(v => new mongoose.Types.ObjectId(v));
276
+ .filter((v) => mongoose.isValidObjectId(v))
277
+ .map((v) => new mongoose.Types.ObjectId(v));
263
278
  } else {
264
279
  sanitizedObj[op] = val;
265
280
  }
@@ -286,21 +301,49 @@ export class ApiFeatures {
286
301
  return value;
287
302
  }
288
303
 
304
+ #isVowel(char) {
305
+ return ["a", "e", "i", "o", "u"].includes(char.toLowerCase());
306
+ }
307
+ #getCollectionName(modelName) {
308
+ const lowerName = modelName.toLowerCase();
309
+ const lastChar = lowerName.slice(-1);
310
+ const lastTwoChars = lowerName.slice(-2);
311
+
312
+ if (lastChar === "y") {
313
+ const secondLastChar = lowerName.slice(-2, -1);
314
+ if (!this.#isVowel(secondLastChar)) {
315
+ return lowerName.slice(0, -1) + "ies";
316
+ }
317
+ }
318
+
319
+ if (
320
+ lastTwoChars === "ch" ||
321
+ lastTwoChars === "sh" ||
322
+ lastChar === "s" ||
323
+ lastChar === "x" ||
324
+ lastChar === "z"
325
+ ) {
326
+ return lowerName + "es";
327
+ }
328
+
329
+ return lowerName + "s";
330
+ }
289
331
  #getCollectionInfo(field) {
290
332
  const schemaPath = this.Model.schema.path(field);
291
333
  if (!schemaPath?.options?.ref) {
292
334
  throw new HandleERROR(`Invalid populate field: ${field}`, 400);
293
335
  }
294
-
295
336
  return {
296
- collection: schemaPath.options.ref.toLowerCase()+'s',
297
- isArray: schemaPath.instance === "Array"
337
+ collection: this.#getCollectionName(schemaPath?.options?.ref),
338
+ isArray: schemaPath.instance === "Array",
298
339
  };
299
340
  }
300
341
 
301
342
  #handleError(error) {
302
343
  // ثبت خطا در logger همراه با stack trace
303
- logger.error(`[API Features Error]: ${error.message}`, { stack: error.stack });
344
+ logger.error(`[API Features Error]: ${error.message}`, {
345
+ stack: error.stack,
346
+ });
304
347
  throw error;
305
348
  }
306
349
  }