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.
- package/package.json +1 -1
- package/src/api-features.js +107 -64
package/package.json
CHANGED
package/src/api-features.js
CHANGED
|
@@ -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] || {
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
96
|
-
|
|
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
|
|
102
|
-
|
|
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,
|
|
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
|
-
|
|
187
|
-
? this.Model.aggregate(this.pipeline)
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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:
|
|
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}`, {
|
|
344
|
+
logger.error(`[API Features Error]: ${error.message}`, {
|
|
345
|
+
stack: error.stack,
|
|
346
|
+
});
|
|
304
347
|
throw error;
|
|
305
348
|
}
|
|
306
349
|
}
|