vanta-api 1.4.1 → 1.4.3
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 +510 -119
package/package.json
CHANGED
package/src/api-features.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import mongoose from "mongoose";
|
|
2
2
|
import winston from "winston";
|
|
3
|
+
import pluralize from "pluralize";
|
|
3
4
|
import HandleERROR from "./handleError.js";
|
|
4
5
|
import { securityConfig } from "./config.js";
|
|
5
6
|
import { ObjectId } from "bson";
|
|
@@ -22,7 +23,6 @@ export class ApiFeatures {
|
|
|
22
23
|
this.query = { ...query };
|
|
23
24
|
this.pipeline = [];
|
|
24
25
|
this.manualFilters = {};
|
|
25
|
-
this.populateOptions = [];
|
|
26
26
|
this.useCursor = false;
|
|
27
27
|
|
|
28
28
|
this.userRole =
|
|
@@ -161,11 +161,15 @@ export class ApiFeatures {
|
|
|
161
161
|
const allowedPopulate =
|
|
162
162
|
securityConfig.accessLevels?.[this.userRole]?.allowedPopulate || [];
|
|
163
163
|
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
164
|
+
for (const populateItem of populateList) {
|
|
165
|
+
this._addPopulateStages({
|
|
166
|
+
populateItem,
|
|
167
|
+
parentPath: "",
|
|
168
|
+
parentIsArray: false,
|
|
169
|
+
schema: this.model.schema,
|
|
170
|
+
allowedPopulate,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
169
173
|
|
|
170
174
|
return this;
|
|
171
175
|
}
|
|
@@ -176,10 +180,9 @@ export class ApiFeatures {
|
|
|
176
180
|
|
|
177
181
|
if (options.debug) {
|
|
178
182
|
logger.info("Pipeline:", this.pipeline);
|
|
179
|
-
logger.info("Populate:", this.populateOptions);
|
|
180
183
|
}
|
|
181
184
|
|
|
182
|
-
if (this.pipeline.length > (securityConfig.maxPipelineStages ||
|
|
185
|
+
if (this.pipeline.length > (securityConfig.maxPipelineStages || 80)) {
|
|
183
186
|
throw new HandleERROR("Too many pipeline stages", 400);
|
|
184
187
|
}
|
|
185
188
|
|
|
@@ -212,10 +215,6 @@ export class ApiFeatures {
|
|
|
212
215
|
.readConcern(options.readConcern || "majority");
|
|
213
216
|
}
|
|
214
217
|
|
|
215
|
-
if (this.populateOptions.length) {
|
|
216
|
-
data = await this.model.populate(data, this.populateOptions);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
218
|
return {
|
|
220
219
|
success: true,
|
|
221
220
|
count: countResult?.total || 0,
|
|
@@ -226,6 +225,420 @@ export class ApiFeatures {
|
|
|
226
225
|
}
|
|
227
226
|
}
|
|
228
227
|
|
|
228
|
+
_addPopulateStages({
|
|
229
|
+
populateItem,
|
|
230
|
+
parentPath = "",
|
|
231
|
+
parentIsArray = false,
|
|
232
|
+
schema = null,
|
|
233
|
+
allowedPopulate = [],
|
|
234
|
+
}) {
|
|
235
|
+
if (!populateItem || !populateItem.path) return;
|
|
236
|
+
|
|
237
|
+
const path = populateItem.path;
|
|
238
|
+
const fullPath = parentPath ? `${parentPath}.${path}` : path;
|
|
239
|
+
|
|
240
|
+
if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const info = this._getPopulateInfo({
|
|
245
|
+
schema,
|
|
246
|
+
path,
|
|
247
|
+
fullPath,
|
|
248
|
+
parentPath,
|
|
249
|
+
parentIsArray,
|
|
250
|
+
populateItem,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (!parentIsArray) {
|
|
254
|
+
this.pipeline.push({
|
|
255
|
+
$lookup: {
|
|
256
|
+
from: info.collection,
|
|
257
|
+
localField: info.localField,
|
|
258
|
+
foreignField: "_id",
|
|
259
|
+
as: info.as,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (!info.isArray) {
|
|
264
|
+
this.pipeline.push({
|
|
265
|
+
$unwind: {
|
|
266
|
+
path: `$${info.as}`,
|
|
267
|
+
preserveNullAndEmptyArrays: true,
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (populateItem.populate) {
|
|
273
|
+
const nestedList = this._normalizeNestedPopulate(populateItem.populate);
|
|
274
|
+
|
|
275
|
+
for (const nested of nestedList) {
|
|
276
|
+
this._addPopulateStages({
|
|
277
|
+
populateItem: nested,
|
|
278
|
+
parentPath: info.as,
|
|
279
|
+
parentIsArray: info.isArray,
|
|
280
|
+
schema: info.refSchema,
|
|
281
|
+
allowedPopulate,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (populateItem.select) {
|
|
287
|
+
this._applyPopulateSelect({
|
|
288
|
+
path: info.as,
|
|
289
|
+
select: populateItem.select,
|
|
290
|
+
isArray: info.isArray,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const tempLookupName = this._makeTempLookupName(fullPath);
|
|
298
|
+
|
|
299
|
+
this.pipeline.push({
|
|
300
|
+
$lookup: {
|
|
301
|
+
from: info.collection,
|
|
302
|
+
localField: info.localField,
|
|
303
|
+
foreignField: "_id",
|
|
304
|
+
as: tempLookupName,
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
this.pipeline.push({
|
|
309
|
+
$set: {
|
|
310
|
+
[parentPath]: {
|
|
311
|
+
$map: {
|
|
312
|
+
input: { $ifNull: [`$${parentPath}`, []] },
|
|
313
|
+
as: "item",
|
|
314
|
+
in: {
|
|
315
|
+
$mergeObjects: [
|
|
316
|
+
"$$item",
|
|
317
|
+
{
|
|
318
|
+
[path]: {
|
|
319
|
+
$first: {
|
|
320
|
+
$filter: {
|
|
321
|
+
input: `$${tempLookupName}`,
|
|
322
|
+
as: "joined",
|
|
323
|
+
cond: {
|
|
324
|
+
$eq: ["$$joined._id", `$$item.${path}`],
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
this.pipeline.push({ $unset: tempLookupName });
|
|
338
|
+
|
|
339
|
+
if (populateItem.populate) {
|
|
340
|
+
const nestedList = this._normalizeNestedPopulate(populateItem.populate);
|
|
341
|
+
|
|
342
|
+
for (const nested of nestedList) {
|
|
343
|
+
this._addNestedPopulateInsideArrayItem({
|
|
344
|
+
arrayPath: parentPath,
|
|
345
|
+
objectPath: path,
|
|
346
|
+
populateItem: nested,
|
|
347
|
+
allowedPopulate,
|
|
348
|
+
schema: info.refSchema,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (populateItem.select) {
|
|
354
|
+
this._applyNestedObjectSelectInsideArray({
|
|
355
|
+
arrayPath: parentPath,
|
|
356
|
+
objectPath: path,
|
|
357
|
+
select: populateItem.select,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
_addNestedPopulateInsideArrayItem({
|
|
363
|
+
arrayPath,
|
|
364
|
+
objectPath,
|
|
365
|
+
populateItem,
|
|
366
|
+
allowedPopulate = [],
|
|
367
|
+
schema = null,
|
|
368
|
+
}) {
|
|
369
|
+
if (!populateItem || !populateItem.path) return;
|
|
370
|
+
|
|
371
|
+
const childPath = populateItem.path;
|
|
372
|
+
const fullPath = `${arrayPath}.${objectPath}.${childPath}`;
|
|
373
|
+
|
|
374
|
+
if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const info = this._getPopulateInfo({
|
|
379
|
+
schema,
|
|
380
|
+
path: childPath,
|
|
381
|
+
fullPath,
|
|
382
|
+
parentPath: `${arrayPath}.${objectPath}`,
|
|
383
|
+
parentIsArray: true,
|
|
384
|
+
populateItem,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const tempLookupName = this._makeTempLookupName(fullPath);
|
|
388
|
+
|
|
389
|
+
this.pipeline.push({
|
|
390
|
+
$lookup: {
|
|
391
|
+
from: info.collection,
|
|
392
|
+
localField: `${arrayPath}.${objectPath}.${childPath}`,
|
|
393
|
+
foreignField: "_id",
|
|
394
|
+
as: tempLookupName,
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
this.pipeline.push({
|
|
399
|
+
$set: {
|
|
400
|
+
[arrayPath]: {
|
|
401
|
+
$map: {
|
|
402
|
+
input: { $ifNull: [`$${arrayPath}`, []] },
|
|
403
|
+
as: "item",
|
|
404
|
+
in: {
|
|
405
|
+
$mergeObjects: [
|
|
406
|
+
"$$item",
|
|
407
|
+
{
|
|
408
|
+
[objectPath]: {
|
|
409
|
+
$cond: [
|
|
410
|
+
{ $ne: [`$$item.${objectPath}`, null] },
|
|
411
|
+
{
|
|
412
|
+
$mergeObjects: [
|
|
413
|
+
`$$item.${objectPath}`,
|
|
414
|
+
{
|
|
415
|
+
[childPath]: {
|
|
416
|
+
$first: {
|
|
417
|
+
$filter: {
|
|
418
|
+
input: `$${tempLookupName}`,
|
|
419
|
+
as: "joined",
|
|
420
|
+
cond: {
|
|
421
|
+
$eq: [
|
|
422
|
+
"$$joined._id",
|
|
423
|
+
`$$item.${objectPath}.${childPath}`,
|
|
424
|
+
],
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
],
|
|
431
|
+
},
|
|
432
|
+
`$$item.${objectPath}`,
|
|
433
|
+
],
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
this.pipeline.push({ $unset: tempLookupName });
|
|
444
|
+
|
|
445
|
+
if (populateItem.populate) {
|
|
446
|
+
const nestedList = this._normalizeNestedPopulate(populateItem.populate);
|
|
447
|
+
|
|
448
|
+
for (const nested of nestedList) {
|
|
449
|
+
this._addNestedPopulateInsideArrayItem({
|
|
450
|
+
arrayPath,
|
|
451
|
+
objectPath: `${objectPath}.${childPath}`,
|
|
452
|
+
populateItem: nested,
|
|
453
|
+
allowedPopulate,
|
|
454
|
+
schema: info.refSchema,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (populateItem.select) {
|
|
460
|
+
this._applyNestedObjectSelectInsideArray({
|
|
461
|
+
arrayPath,
|
|
462
|
+
objectPath: `${objectPath}.${childPath}`,
|
|
463
|
+
select: populateItem.select,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
_getPopulateInfo({
|
|
469
|
+
schema = null,
|
|
470
|
+
path,
|
|
471
|
+
fullPath,
|
|
472
|
+
parentPath = "",
|
|
473
|
+
parentIsArray = false,
|
|
474
|
+
populateItem = {},
|
|
475
|
+
}) {
|
|
476
|
+
const schemaPath = schema?.path?.(path);
|
|
477
|
+
|
|
478
|
+
const isArray =
|
|
479
|
+
populateItem.isArray === true ||
|
|
480
|
+
schemaPath?.instance === "Array" ||
|
|
481
|
+
Array.isArray(schemaPath?.options?.type);
|
|
482
|
+
|
|
483
|
+
const refModelName =
|
|
484
|
+
populateItem.ref ||
|
|
485
|
+
populateItem.modelName ||
|
|
486
|
+
schemaPath?.options?.ref ||
|
|
487
|
+
schemaPath?.caster?.options?.ref ||
|
|
488
|
+
(Array.isArray(schemaPath?.options?.type)
|
|
489
|
+
? schemaPath.options.type[0]?.ref
|
|
490
|
+
: undefined) ||
|
|
491
|
+
this._inferModelNameFromPath(path);
|
|
492
|
+
|
|
493
|
+
const collection =
|
|
494
|
+
populateItem.collection ||
|
|
495
|
+
populateItem.from ||
|
|
496
|
+
this._resolveCollectionName(refModelName);
|
|
497
|
+
|
|
498
|
+
const refSchema = this._resolveRegisteredSchema(refModelName);
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
path,
|
|
502
|
+
fullPath,
|
|
503
|
+
refModelName,
|
|
504
|
+
collection,
|
|
505
|
+
refSchema,
|
|
506
|
+
isArray,
|
|
507
|
+
localField: parentIsArray ? fullPath : fullPath,
|
|
508
|
+
as: fullPath,
|
|
509
|
+
parentPath,
|
|
510
|
+
parentIsArray,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
_applyPopulateSelect({ path, select, isArray }) {
|
|
515
|
+
const parsed = this._parseSelect(select);
|
|
516
|
+
|
|
517
|
+
if (!parsed.fields.length) return;
|
|
518
|
+
|
|
519
|
+
if (parsed.mode === "exclude") {
|
|
520
|
+
this.pipeline.push({
|
|
521
|
+
$unset: parsed.fields.map((field) => `${path}.${field}`),
|
|
522
|
+
});
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const includeFields = this._ensureIdField(parsed.fields);
|
|
527
|
+
|
|
528
|
+
if (isArray) {
|
|
529
|
+
const selectedObject = {};
|
|
530
|
+
|
|
531
|
+
for (const field of includeFields) {
|
|
532
|
+
selectedObject[field] = `$$item.${field}`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
this.pipeline.push({
|
|
536
|
+
$set: {
|
|
537
|
+
[path]: {
|
|
538
|
+
$map: {
|
|
539
|
+
input: { $ifNull: [`$${path}`, []] },
|
|
540
|
+
as: "item",
|
|
541
|
+
in: selectedObject,
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const selectedObject = {};
|
|
551
|
+
|
|
552
|
+
for (const field of includeFields) {
|
|
553
|
+
selectedObject[field] = `$${path}.${field}`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
this.pipeline.push({
|
|
557
|
+
$set: {
|
|
558
|
+
[path]: {
|
|
559
|
+
$cond: [{ $ne: [`$${path}`, null] }, selectedObject, `$${path}`],
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
_applyNestedObjectSelectInsideArray({ arrayPath, objectPath, select }) {
|
|
566
|
+
const parsed = this._parseSelect(select);
|
|
567
|
+
|
|
568
|
+
if (!parsed.fields.length) return;
|
|
569
|
+
|
|
570
|
+
if (parsed.mode === "exclude") {
|
|
571
|
+
this.pipeline.push({
|
|
572
|
+
$unset: parsed.fields.map(
|
|
573
|
+
(field) => `${arrayPath}.${objectPath}.${field}`
|
|
574
|
+
),
|
|
575
|
+
});
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const includeFields = this._ensureIdField(parsed.fields);
|
|
580
|
+
const selectedObject = {};
|
|
581
|
+
|
|
582
|
+
for (const field of includeFields) {
|
|
583
|
+
selectedObject[field] = `$$item.${objectPath}.${field}`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
this.pipeline.push({
|
|
587
|
+
$set: {
|
|
588
|
+
[arrayPath]: {
|
|
589
|
+
$map: {
|
|
590
|
+
input: { $ifNull: [`$${arrayPath}`, []] },
|
|
591
|
+
as: "item",
|
|
592
|
+
in: {
|
|
593
|
+
$mergeObjects: [
|
|
594
|
+
"$$item",
|
|
595
|
+
{
|
|
596
|
+
[objectPath]: {
|
|
597
|
+
$cond: [
|
|
598
|
+
{ $ne: [`$$item.${objectPath}`, null] },
|
|
599
|
+
selectedObject,
|
|
600
|
+
`$$item.${objectPath}`,
|
|
601
|
+
],
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
],
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
_parseSelect(select = "") {
|
|
613
|
+
const fields = String(select)
|
|
614
|
+
.split(/\s+/)
|
|
615
|
+
.map((field) => field.trim())
|
|
616
|
+
.filter(Boolean)
|
|
617
|
+
.filter((field) => {
|
|
618
|
+
const cleanField = field.replace(/^-/, "");
|
|
619
|
+
return !this._isForbiddenField(cleanField);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const hasInclude = fields.some((field) => !field.startsWith("-"));
|
|
623
|
+
const hasExclude = fields.some((field) => field.startsWith("-"));
|
|
624
|
+
|
|
625
|
+
if (hasInclude && hasExclude) {
|
|
626
|
+
throw new HandleERROR(
|
|
627
|
+
"Cannot mix include and exclude in populate select",
|
|
628
|
+
400
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return {
|
|
633
|
+
mode: hasExclude ? "exclude" : "include",
|
|
634
|
+
fields: fields.map((field) => field.replace(/^-/, "")),
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
_ensureIdField(fields = []) {
|
|
639
|
+
return fields.includes("_id") ? fields : ["_id", ...fields];
|
|
640
|
+
}
|
|
641
|
+
|
|
229
642
|
_sanitization() {
|
|
230
643
|
for (const key of Object.keys(this.query)) {
|
|
231
644
|
if (
|
|
@@ -291,43 +704,50 @@ export class ApiFeatures {
|
|
|
291
704
|
return out;
|
|
292
705
|
}
|
|
293
706
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
707
|
+
_sanitizeFilters(filters = {}) {
|
|
708
|
+
const sanitizeNode = (node, key = "") => {
|
|
709
|
+
if (
|
|
710
|
+
node instanceof mongoose.Types.ObjectId ||
|
|
711
|
+
node instanceof ObjectId
|
|
712
|
+
) {
|
|
713
|
+
return node;
|
|
714
|
+
}
|
|
299
715
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
716
|
+
if (node === null || node === "null") return null;
|
|
717
|
+
if (node === "true") return true;
|
|
718
|
+
if (node === "false") return false;
|
|
303
719
|
|
|
304
|
-
|
|
305
|
-
|
|
720
|
+
if (Array.isArray(node)) {
|
|
721
|
+
return node.map((item) => sanitizeNode(item, key));
|
|
722
|
+
}
|
|
306
723
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
724
|
+
if (node && typeof node === "object") {
|
|
725
|
+
const result = {};
|
|
310
726
|
|
|
311
|
-
|
|
727
|
+
for (const [childKey, childVal] of Object.entries(node)) {
|
|
728
|
+
result[childKey] = sanitizeNode(childVal, childKey);
|
|
312
729
|
}
|
|
313
730
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
return new ObjectId(node);
|
|
317
|
-
}
|
|
731
|
+
return result;
|
|
732
|
+
}
|
|
318
733
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
: parseInt(node, 10);
|
|
323
|
-
}
|
|
734
|
+
if (typeof node === "string") {
|
|
735
|
+
if (this.#isStrictObjectId(node) && this._shouldConvertToObjectId(key)) {
|
|
736
|
+
return new ObjectId(node);
|
|
324
737
|
}
|
|
325
738
|
|
|
326
|
-
|
|
327
|
-
|
|
739
|
+
if (/^[0-9]+$/.test(node)) {
|
|
740
|
+
return node.length > 1 && node.startsWith("0")
|
|
741
|
+
? node
|
|
742
|
+
: parseInt(node, 10);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
328
745
|
|
|
329
|
-
return
|
|
330
|
-
}
|
|
746
|
+
return node;
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
return sanitizeNode(filters);
|
|
750
|
+
}
|
|
331
751
|
|
|
332
752
|
_shouldConvertToObjectId(key = "") {
|
|
333
753
|
const cleanKey = String(key).replace(/^\$/, "").toLowerCase();
|
|
@@ -443,7 +863,7 @@ export class ApiFeatures {
|
|
|
443
863
|
}
|
|
444
864
|
|
|
445
865
|
if (typeof normalized.select === "string") {
|
|
446
|
-
normalized.select =
|
|
866
|
+
normalized.select = normalized.select.trim();
|
|
447
867
|
}
|
|
448
868
|
|
|
449
869
|
if (normalized.populate) {
|
|
@@ -454,7 +874,7 @@ export class ApiFeatures {
|
|
|
454
874
|
}
|
|
455
875
|
|
|
456
876
|
_normalizeNestedPopulate(input) {
|
|
457
|
-
if (!input) return
|
|
877
|
+
if (!input) return [];
|
|
458
878
|
|
|
459
879
|
if (typeof input === "string") {
|
|
460
880
|
return this._normalizePopulateInput(input);
|
|
@@ -473,14 +893,17 @@ export class ApiFeatures {
|
|
|
473
893
|
}
|
|
474
894
|
|
|
475
895
|
if (typeof input === "object" && input.path) {
|
|
476
|
-
return this._normalizePopulateObject(input);
|
|
896
|
+
return [this._normalizePopulateObject(input)];
|
|
477
897
|
}
|
|
478
898
|
|
|
479
|
-
return
|
|
899
|
+
return [];
|
|
480
900
|
}
|
|
481
901
|
|
|
482
902
|
_dotPathToPopulate(path) {
|
|
483
|
-
const parts = path
|
|
903
|
+
const parts = path
|
|
904
|
+
.split(".")
|
|
905
|
+
.map((part) => part.trim())
|
|
906
|
+
.filter(Boolean);
|
|
484
907
|
|
|
485
908
|
const root = { path: parts[0] };
|
|
486
909
|
let current = root;
|
|
@@ -515,96 +938,60 @@ export class ApiFeatures {
|
|
|
515
938
|
const merged = { ...a, ...b };
|
|
516
939
|
|
|
517
940
|
if (a.populate || b.populate) {
|
|
518
|
-
const aList =
|
|
519
|
-
|
|
941
|
+
const aList = Array.isArray(a.populate)
|
|
942
|
+
? a.populate
|
|
943
|
+
: a.populate
|
|
944
|
+
? [a.populate]
|
|
945
|
+
: [];
|
|
946
|
+
|
|
947
|
+
const bList = Array.isArray(b.populate)
|
|
948
|
+
? b.populate
|
|
949
|
+
: b.populate
|
|
950
|
+
? [b.populate]
|
|
951
|
+
: [];
|
|
952
|
+
|
|
520
953
|
merged.populate = this._dedupePopulate([...aList, ...bList]);
|
|
521
954
|
}
|
|
522
955
|
|
|
523
956
|
return merged;
|
|
524
957
|
}
|
|
525
958
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
959
|
+
_isPopulateAllowed(path, allowedPopulate = []) {
|
|
960
|
+
return (
|
|
961
|
+
allowedPopulate.includes("*") ||
|
|
962
|
+
allowedPopulate.includes(path) ||
|
|
963
|
+
allowedPopulate.includes(path.split(".")[0])
|
|
964
|
+
);
|
|
529
965
|
}
|
|
530
966
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const fullPath = parentPath ? `${parentPath}.${item.path}` : item.path;
|
|
535
|
-
|
|
536
|
-
if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
|
|
537
|
-
return null;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
const sanitized = {
|
|
541
|
-
path: item.path,
|
|
542
|
-
};
|
|
543
|
-
|
|
544
|
-
if (item.select) {
|
|
545
|
-
sanitized.select = this._sanitizeSelectString(item.select);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
if (item.match && typeof item.match === "object") {
|
|
549
|
-
sanitized.match = this._applySecurityFilters(
|
|
550
|
-
this._sanitizeFilters(item.match)
|
|
551
|
-
);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
if (item.options && typeof item.options === "object") {
|
|
555
|
-
sanitized.options = item.options;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
if (item.model) {
|
|
559
|
-
sanitized.model = item.model;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
if (item.populate) {
|
|
563
|
-
const nested = this._populateToArray(item.populate)
|
|
564
|
-
.map((child) =>
|
|
565
|
-
this._sanitizePopulateOption(child, fullPath, allowedPopulate)
|
|
566
|
-
)
|
|
567
|
-
.filter(Boolean);
|
|
967
|
+
_resolveCollectionName(refModelName = "") {
|
|
968
|
+
return pluralize(String(refModelName).toLowerCase());
|
|
969
|
+
}
|
|
568
970
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
}
|
|
574
|
-
}
|
|
971
|
+
_resolveRegisteredSchema(refModelName = "") {
|
|
972
|
+
const connection = this.model?.db || mongoose.connection;
|
|
973
|
+
const registeredModel =
|
|
974
|
+
connection.models?.[refModelName] || mongoose.models?.[refModelName];
|
|
575
975
|
|
|
576
|
-
return
|
|
976
|
+
return registeredModel?.schema || null;
|
|
577
977
|
}
|
|
578
978
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
.split(/\s+/)
|
|
582
|
-
.map((field) => field.trim())
|
|
583
|
-
.filter(Boolean)
|
|
584
|
-
.filter((field) => {
|
|
585
|
-
const cleanField = field.replace(/^-/, "");
|
|
586
|
-
return !this._isForbiddenField(cleanField);
|
|
587
|
-
});
|
|
979
|
+
_inferModelNameFromPath(path = "") {
|
|
980
|
+
let clean = String(path).split(".").pop();
|
|
588
981
|
|
|
589
|
-
|
|
590
|
-
|
|
982
|
+
clean = clean.replace(/Ids$/i, "");
|
|
983
|
+
clean = clean.replace(/Id$/i, "");
|
|
984
|
+
clean = pluralize.singular(clean);
|
|
591
985
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
return fields.join(" ");
|
|
986
|
+
return clean
|
|
987
|
+
.split(/[_-\s]+/)
|
|
988
|
+
.filter(Boolean)
|
|
989
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
990
|
+
.join("");
|
|
600
991
|
}
|
|
601
992
|
|
|
602
|
-
|
|
603
|
-
return (
|
|
604
|
-
allowedPopulate.includes("*") ||
|
|
605
|
-
allowedPopulate.includes(path) ||
|
|
606
|
-
allowedPopulate.includes(path.split(".")[0])
|
|
607
|
-
);
|
|
993
|
+
_makeTempLookupName(path = "") {
|
|
994
|
+
return `__vanta_lookup_${String(path).replace(/[^a-zA-Z0-9]/g, "_")}`;
|
|
608
995
|
}
|
|
609
996
|
|
|
610
997
|
_isForbiddenField(field) {
|
|
@@ -617,7 +1004,11 @@ export class ApiFeatures {
|
|
|
617
1004
|
"$skip" in stage ||
|
|
618
1005
|
"$limit" in stage ||
|
|
619
1006
|
"$sort" in stage ||
|
|
620
|
-
"$project" in stage
|
|
1007
|
+
"$project" in stage ||
|
|
1008
|
+
"$lookup" in stage ||
|
|
1009
|
+
"$unwind" in stage ||
|
|
1010
|
+
"$set" in stage ||
|
|
1011
|
+
"$unset" in stage
|
|
621
1012
|
);
|
|
622
1013
|
});
|
|
623
1014
|
}
|