vanta-api 1.0.5 → 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 +108 -73
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanta-api",
3
- "version": "1.0.5",
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": {
@@ -3,14 +3,13 @@ import winston from "winston";
3
3
  import { securityConfig } from "./config.js";
4
4
  import HandleERROR from "./handleError.js";
5
5
 
6
- // تنظیم logger با winston
7
6
  const logger = winston.createLogger({
8
7
  level: "info",
9
8
  format: winston.format.combine(
10
9
  winston.format.timestamp(),
11
10
  winston.format.json()
12
11
  ),
13
- transports: [new winston.transports.Console()]
12
+ transports: [new winston.transports.Console()],
14
13
  });
15
14
 
16
15
  export class ApiFeatures {
@@ -21,7 +20,6 @@ export class ApiFeatures {
21
20
  this.pipeline = [];
22
21
  this.countPipeline = [];
23
22
  this.manualFilters = {};
24
- // انتخاب استفاده از cursor برای پردازش داده‌های حجیم
25
23
  this.useCursor = false;
26
24
  this.#initialSanitization();
27
25
  }
@@ -33,7 +31,6 @@ export class ApiFeatures {
33
31
  const safeFilters = this.#applySecurityFilters(mergedFilters);
34
32
 
35
33
  if (Object.keys(safeFilters).length > 0) {
36
- // اضافه کردن فیلتر به ابتدای pipeline جهت بهبود عملکرد
37
34
  this.pipeline.push({ $match: safeFilters });
38
35
  this.countPipeline.push({ $match: safeFilters });
39
36
  }
@@ -58,7 +55,7 @@ export class ApiFeatures {
58
55
  if (this.query.fields) {
59
56
  const allowedFields = this.query.fields
60
57
  .split(",")
61
- .filter(f => !securityConfig.forbiddenFields.includes(f))
58
+ .filter((f) => !securityConfig.forbiddenFields.includes(f))
62
59
  .reduce((acc, curr) => ({ ...acc, [curr]: 1 }), {});
63
60
 
64
61
  this.pipeline.push({ $project: allowedFields });
@@ -67,25 +64,21 @@ export class ApiFeatures {
67
64
  }
68
65
 
69
66
  paginate() {
70
- const { maxLimit } = securityConfig.accessLevels[this.userRole] || { maxLimit: 100 };
67
+ const { maxLimit } = securityConfig.accessLevels[this.userRole] || {
68
+ maxLimit: 100,
69
+ };
71
70
  const page = Math.max(parseInt(this.query.page, 10) || 1, 1);
72
- const limit = Math.min(
73
- parseInt(this.query.limit, 10) || 10,
74
- maxLimit
75
- );
76
-
77
- this.pipeline.push(
78
- { $skip: (page - 1) * limit },
79
- { $limit: limit }
80
- );
71
+ const limit = Math.min(parseInt(this.query.limit, 10) || 10, maxLimit);
72
+
73
+ this.pipeline.push({ $skip: (page - 1) * limit }, { $limit: limit });
81
74
  return this;
82
75
  }
83
76
 
84
77
  populate(input = "") {
85
78
  let populateOptions = [];
86
-
79
+
87
80
  if (Array.isArray(input)) {
88
- input.forEach(item => {
81
+ input.forEach((item) => {
89
82
  if (typeof item === "object" && item.path) {
90
83
  populateOptions.push(item);
91
84
  } else if (typeof item === "string") {
@@ -95,19 +88,25 @@ export class ApiFeatures {
95
88
  } else if (typeof input === "object" && input.path) {
96
89
  populateOptions.push(input);
97
90
  } else if (typeof input === "string" && input.trim().length > 0) {
98
- input.split(",").filter(Boolean).forEach(item => {
99
- populateOptions.push(item.trim());
100
- });
91
+ input
92
+ .split(",")
93
+ .filter(Boolean)
94
+ .forEach((item) => {
95
+ populateOptions.push(item.trim());
96
+ });
101
97
  }
102
-
98
+
103
99
  if (this.query.populate) {
104
- this.query.populate.split(",").filter(Boolean).forEach(item => {
105
- populateOptions.push(item.trim());
106
- });
100
+ this.query.populate
101
+ .split(",")
102
+ .filter(Boolean)
103
+ .forEach((item) => {
104
+ populateOptions.push(item.trim());
105
+ });
107
106
  }
108
-
107
+
109
108
  const uniqueMap = new Map();
110
- populateOptions.forEach(item => {
109
+ populateOptions.forEach((item) => {
111
110
  if (typeof item === "object" && item.path) {
112
111
  uniqueMap.set(item.path, item);
113
112
  } else if (typeof item === "string") {
@@ -115,39 +114,40 @@ export class ApiFeatures {
115
114
  }
116
115
  });
117
116
  const uniquePopulateOptions = Array.from(uniqueMap.values());
118
-
119
- uniquePopulateOptions.forEach(option => {
120
- let field, projection = {};
117
+
118
+ uniquePopulateOptions.forEach((option) => {
119
+ let field,
120
+ projection = {};
121
121
  if (typeof option === "object") {
122
122
  field = option.path;
123
123
  if (option.select) {
124
- option.select.split(" ").forEach(fieldName => {
124
+ option.select.split(" ").forEach((fieldName) => {
125
125
  if (fieldName) projection[fieldName.trim()] = 1;
126
126
  });
127
127
  }
128
128
  } else if (typeof option === "string") {
129
129
  field = option;
130
130
  }
131
-
131
+
132
132
  field = field.trim();
133
133
  const { collection, isArray } = this.#getCollectionInfo(field);
134
-
134
+
135
135
  let lookupStage = {};
136
136
  if (Object.keys(projection).length > 0) {
137
137
  lookupStage = {
138
138
  $lookup: {
139
139
  from: collection,
140
- let: { localField: `$${field}` },
140
+ let: { localId: `$${field}` },
141
141
  pipeline: [
142
142
  {
143
143
  $match: {
144
- $expr: { $eq: ["$_id", "$$localField"] }
145
- }
144
+ $expr: { $eq: ["$_id", "$$localId"] },
145
+ },
146
146
  },
147
- { $project: projection }
147
+ { $project: projection },
148
148
  ],
149
- as: field
150
- }
149
+ as: field,
150
+ },
151
151
  };
152
152
  } else {
153
153
  lookupStage = {
@@ -155,23 +155,23 @@ export class ApiFeatures {
155
155
  from: collection,
156
156
  localField: field,
157
157
  foreignField: "_id",
158
- as: field
159
- }
158
+ as: field,
159
+ },
160
160
  };
161
161
  }
162
-
162
+
163
163
  this.pipeline.push(lookupStage);
164
164
  this.pipeline.push({
165
165
  $unwind: {
166
166
  path: `$${field}`,
167
- preserveNullAndEmptyArrays: true
168
- }
167
+ preserveNullAndEmptyArrays: true,
168
+ },
169
169
  });
170
170
  });
171
-
171
+
172
172
  return this;
173
173
  }
174
-
174
+
175
175
  addManualFilters(filters) {
176
176
  if (filters) {
177
177
  this.manualFilters = { ...this.manualFilters, ...filters };
@@ -186,12 +186,13 @@ export class ApiFeatures {
186
186
  }
187
187
  const [countResult, dataResult] = await Promise.all([
188
188
  this.Model.aggregate([...this.countPipeline, { $count: "total" }]),
189
- (this.useCursor
190
- ? this.Model.aggregate(this.pipeline).cursor({ batchSize: 100 }).exec()
189
+ this.useCursor
190
+ ? this.Model.aggregate(this.pipeline)
191
+ .cursor({ batchSize: 100 })
192
+ .exec()
191
193
  : this.Model.aggregate(this.pipeline)
192
194
  .allowDiskUse(options.allowDiskUse || false)
193
- .readConcern("majority")
194
- )
195
+ .readConcern("majority"),
195
196
  ]);
196
197
 
197
198
  const count = countResult[0]?.total || 0;
@@ -204,11 +205,11 @@ export class ApiFeatures {
204
205
  } else {
205
206
  data = dataResult;
206
207
  }
207
-
208
+
208
209
  return {
209
210
  success: true,
210
211
  count,
211
- data
212
+ data,
212
213
  };
213
214
  } catch (error) {
214
215
  this.#handleError(error);
@@ -217,11 +218,11 @@ export class ApiFeatures {
217
218
 
218
219
  // ---------- Security and Sanitization Methods ----------
219
220
  #initialSanitization() {
220
- ["$where", "$accumulator", "$function"].forEach(op => {
221
+ ["$where", "$accumulator", "$function"].forEach((op) => {
221
222
  delete this.query[op];
222
223
  delete this.manualFilters[op];
223
224
  });
224
- ["page", "limit"].forEach(field => {
225
+ ["page", "limit"].forEach((field) => {
225
226
  if (this.query[field] && !/^\d+$/.test(this.query[field])) {
226
227
  throw new HandleERROR(`Invalid value for ${field}`, 400);
227
228
  }
@@ -230,39 +231,50 @@ export class ApiFeatures {
230
231
 
231
232
  #parseQueryFilters() {
232
233
  const queryObj = { ...this.query };
233
- ["page", "limit", "sort", "fields", "populate"].forEach(el => delete queryObj[el]);
234
+ ["page", "limit", "sort", "fields", "populate"].forEach(
235
+ (el) => delete queryObj[el]
236
+ );
234
237
 
235
238
  return JSON.parse(
236
- JSON.stringify(queryObj)
237
- .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
+ )
238
243
  );
239
244
  }
240
245
 
241
246
  #applySecurityFilters(filters) {
242
247
  let result = { ...filters };
243
-
244
- securityConfig.forbiddenFields.forEach(field => delete result[field]);
245
-
248
+
249
+ securityConfig.forbiddenFields.forEach((field) => delete result[field]);
250
+
246
251
  if (this.userRole !== "admin" && this.Model.schema.path("isActive")) {
247
252
  result.isActive = true;
248
253
  result = this.#sanitizeNestedObjects(result);
249
254
  }
250
-
255
+
251
256
  return result;
252
257
  }
253
258
 
254
259
  #sanitizeNestedObjects(obj) {
255
260
  return Object.entries(obj).reduce((acc, [key, value]) => {
256
261
  // Handle ObjectId fields with nested operators
257
- 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
+ ) {
258
267
  const sanitizedObj = {};
259
268
  for (const [op, val] of Object.entries(value)) {
260
- 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
+ ) {
261
273
  sanitizedObj[op] = new mongoose.Types.ObjectId(val);
262
274
  } else if (["$in", "$nin"].includes(op) && Array.isArray(val)) {
263
275
  sanitizedObj[op] = val
264
- .filter(v => mongoose.isValidObjectId(v))
265
- .map(v => new mongoose.Types.ObjectId(v));
276
+ .filter((v) => mongoose.isValidObjectId(v))
277
+ .map((v) => new mongoose.Types.ObjectId(v));
266
278
  } else {
267
279
  sanitizedObj[op] = val;
268
280
  }
@@ -289,26 +301,49 @@ export class ApiFeatures {
289
301
  return value;
290
302
  }
291
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
+ }
292
331
  #getCollectionInfo(field) {
293
332
  const schemaPath = this.Model.schema.path(field);
294
333
  if (!schemaPath?.options?.ref) {
295
334
  throw new HandleERROR(`Invalid populate field: ${field}`, 400);
296
335
  }
297
-
298
- const refModel = mongoose.model(schemaPath.options.ref);
299
- if (refModel.schema.options.restricted && this.userRole !== "admin") {
300
- throw new HandleERROR(`Unauthorized to populate ${field}`, 403);
301
- }
302
-
303
336
  return {
304
- collection: refModel.collection.name,
305
- isArray: schemaPath.instance === "Array"
337
+ collection: this.#getCollectionName(schemaPath?.options?.ref),
338
+ isArray: schemaPath.instance === "Array",
306
339
  };
307
340
  }
308
341
 
309
342
  #handleError(error) {
310
343
  // ثبت خطا در logger همراه با stack trace
311
- logger.error(`[API Features Error]: ${error.message}`, { stack: error.stack });
344
+ logger.error(`[API Features Error]: ${error.message}`, {
345
+ stack: error.stack,
346
+ });
312
347
  throw error;
313
348
  }
314
349
  }