vanta-api 1.3.2 → 1.4.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.
Files changed (3) hide show
  1. package/README.md +986 -220
  2. package/package.json +1 -1
  3. package/src/api-features.js +476 -233
@@ -4,7 +4,7 @@ import pluralize from "pluralize";
4
4
  import HandleERROR from "./handleError.js";
5
5
  import { securityConfig } from "./config.js";
6
6
  import { ObjectId } from "bson";
7
- // Logger setup
7
+
8
8
  const logger = winston.createLogger({
9
9
  level: "info",
10
10
  format: winston.format.combine(
@@ -14,90 +14,116 @@ const logger = winston.createLogger({
14
14
  transports: [new winston.transports.Console()],
15
15
  });
16
16
 
17
+ const RESERVED_QUERY_KEYS = ["page", "limit", "sort", "fields", "populate", "q"];
18
+
17
19
  export class ApiFeatures {
18
20
  constructor(model, query = {}, userRole = "") {
19
21
  this.model = model;
20
22
  this.query = { ...query };
21
- if (
22
- !userRole ||
23
- !Object.keys(securityConfig.accessLevels).includes(userRole)
24
- ) {
25
- this.userRole = "guest";
26
- } else {
27
- this.userRole = userRole;
28
- }
29
-
30
23
  this.pipeline = [];
31
- this.countPipeline = [];
32
24
  this.manualFilters = {};
33
25
  this.useCursor = false;
34
26
 
27
+ this.userRole =
28
+ userRole && securityConfig.accessLevels?.[userRole] ? userRole : "guest";
29
+
35
30
  this._sanitization();
36
31
  }
37
32
 
38
- // ---------- Core Methods ----------
39
-
40
33
  filter() {
41
- // Parse and sanitize both query and manual filters
42
34
  const queryFilters = this._parseQueryFilters();
43
- const merged = this._sanitizeFilters({
44
- ...queryFilters,
45
- ...this.manualFilters,
46
- });
47
- const safe = this._applySecurityFilters(merged);
35
+ const mergedFilters = this._deepMergeFilters(queryFilters, this.manualFilters);
36
+ const sanitizedFilters = this._sanitizeFilters(mergedFilters);
37
+ const safeFilters = this._applySecurityFilters(sanitizedFilters);
48
38
 
49
- if (Object.keys(safe).length) {
50
- this.pipeline.push({ $match: safe });
51
- this.countPipeline.push({ $match: safe });
39
+ if (Object.keys(safeFilters).length) {
40
+ this.pipeline.push({ $match: safeFilters });
52
41
  }
42
+
43
+ return this;
44
+ }
45
+
46
+ addManualFilters(filters = {}) {
47
+ if (filters && typeof filters === "object" && !Array.isArray(filters)) {
48
+ this.manualFilters = this._deepMergeFilters(this.manualFilters, filters);
49
+ }
50
+
51
+ return this;
52
+ }
53
+
54
+ search(fields = []) {
55
+ const q = this.query.q;
56
+
57
+ if (!q || !Array.isArray(fields) || !fields.length) return this;
58
+
59
+ const safeQ = this._escapeRegex(String(q).trim());
60
+
61
+ if (!safeQ) return this;
62
+
63
+ this.pipeline.push({
64
+ $match: {
65
+ $or: fields
66
+ .filter((field) => typeof field === "string" && field.trim())
67
+ .map((field) => ({
68
+ [field]: { $regex: safeQ, $options: "i" },
69
+ })),
70
+ },
71
+ });
72
+
53
73
  return this;
54
74
  }
55
75
 
56
76
  sort() {
57
77
  if (!this.query.sort) return this;
58
- const parts = this.query.sort.split(",");
59
- const validFields = Object.keys(this.model.schema.paths);
78
+
60
79
  const sortObj = {};
80
+ const validFields = new Set(Object.keys(this.model.schema.paths));
61
81
 
62
- for (const part of parts) {
63
- const dir = part.startsWith("-") ? -1 : 1;
64
- const key = part.replace(/^[-+]/, "");
65
- if (validFields.includes(key)) sortObj[key] = dir;
82
+ String(this.query.sort)
83
+ .split(",")
84
+ .map((p) => p.trim())
85
+ .filter(Boolean)
86
+ .forEach((part) => {
87
+ const direction = part.startsWith("-") ? -1 : 1;
88
+ const field = part.replace(/^[-+]/, "");
89
+
90
+ if (validFields.has(field)) {
91
+ sortObj[field] = direction;
92
+ }
93
+ });
94
+
95
+ if (Object.keys(sortObj).length) {
96
+ this.pipeline.push({ $sort: sortObj });
66
97
  }
67
98
 
68
- if (Object.keys(sortObj).length) this.pipeline.push({ $sort: sortObj });
69
99
  return this;
70
100
  }
71
101
 
72
102
  limitFields(input = "") {
73
103
  const rawFields = [input, this.query.fields].filter(Boolean).join(",");
74
- if (!rawFields) return this;
75
104
 
76
- const validFields = Object.keys(this.model.schema.paths).filter(
77
- (f) => !securityConfig.forbiddenFields.includes(f)
78
- );
105
+ if (!rawFields) return this;
79
106
 
80
- const fieldsArray = rawFields
107
+ const fields = rawFields
81
108
  .split(",")
82
- .map((f) => f.trim())
109
+ .map((field) => field.trim())
83
110
  .filter(Boolean);
84
111
 
85
- const includeFields = new Set();
86
- const excludeFields = new Set();
112
+ const hasInclude = fields.some((field) => !field.startsWith("-"));
113
+ const hasExclude = fields.some((field) => field.startsWith("-"));
114
+
115
+ if (hasInclude && hasExclude) {
116
+ throw new HandleERROR("Cannot mix include and exclude fields", 400);
117
+ }
87
118
 
88
- fieldsArray.forEach((f) => {
89
- if (f.startsWith("-")) excludeFields.add(f.slice(1));
90
- else includeFields.add(f);
91
- });
92
119
  const project = {};
93
- if (includeFields.size > 0) {
94
- includeFields.forEach((f) => {
95
- if (validFields.includes(f)) project[f] = 1;
96
- });
97
- } else if (excludeFields.size > 0) {
98
- validFields.forEach((f) => {
99
- if (!excludeFields.has(f)) project[f] = 1;
100
- });
120
+
121
+ for (const field of fields) {
122
+ const cleanField = field.replace(/^-/, "");
123
+
124
+ if (this._isForbiddenField(cleanField)) continue;
125
+
126
+ project[cleanField] = field.startsWith("-") ? 0 : 1;
101
127
  }
102
128
 
103
129
  if (Object.keys(project).length) {
@@ -106,271 +132,488 @@ export class ApiFeatures {
106
132
 
107
133
  return this;
108
134
  }
135
+
109
136
  paginate() {
110
- const { maxLimit } = securityConfig.accessLevels[this.userRole] || {
111
- maxLimit: 100,
112
- };
137
+ const access = securityConfig.accessLevels?.[this.userRole] || {};
138
+ const maxLimit = access.maxLimit || 100;
139
+
113
140
  const page = Math.max(parseInt(this.query.page, 10) || 1, 1);
114
- const lim = Math.min(
141
+ const limit = Math.min(
115
142
  Math.max(parseInt(this.query.limit, 10) || 10, 1),
116
143
  maxLimit
117
144
  );
118
145
 
119
- this.pipeline.push({ $skip: (page - 1) * lim }, { $limit: lim });
146
+ this.pipeline.push({ $skip: (page - 1) * limit }, { $limit: limit });
147
+
120
148
  return this;
121
149
  }
122
150
 
123
151
  populate(input = "") {
124
- let list = [];
125
- const raw = Array.isArray(input) ? input : [input];
126
- if (this.query.populate) raw.push(...this.query.populate.split(","));
127
-
128
- raw.forEach((item) => {
129
- if (typeof item === "string" && item.trim()) list.push(item.trim());
130
- else if (item?.path) list.push(item);
131
- });
132
-
133
- const map = new Map();
134
- list.forEach((opt) => {
135
- const key = typeof opt === "string" ? opt : opt.path;
136
- map.set(key, opt);
137
- });
138
-
139
- const allowed =
140
- securityConfig.accessLevels[this.userRole]?.allowedPopulate || [];
141
- const final = [];
142
- map.forEach((opt, key) => {
143
- if (allowed.includes("*") || allowed.includes(key)) final.push(opt);
144
- });
145
-
146
- for (const opt of final) {
147
- const field = typeof opt === "string" ? opt : opt.path;
148
- const proj =
149
- typeof opt === "object" && opt.select
150
- ? opt.select.split(" ").reduce(
151
- (a, f) => {
152
- a[f] = 1;
153
- return a;
154
- },
155
- { _id: 1 }
156
- )
157
- : {};
158
-
159
- const { collection, isArray } = this._getCollectionInfo(field);
160
-
161
- const matchStage = isArray
162
- ? { $match: { $expr: { $in: ["$_id", "$$id"] } } }
163
- : { $match: { $expr: { $eq: ["$_id", "$$id"] } } };
164
-
165
- const lookup =
166
- proj && Object.keys(proj).length
167
- ? {
168
- from: collection,
169
- let: { id: `$${field}` },
170
- pipeline: [matchStage, { $project: proj }],
171
- as: field,
172
- }
173
- : {
174
- from: collection,
175
- localField: field,
176
- foreignField: "_id",
177
- as: field,
178
- };
179
-
180
- this.pipeline.push({ $lookup: lookup });
181
-
182
- if (!isArray) {
183
- this.pipeline.push({
184
- $unwind: { path: `$${field}`, preserveNullAndEmptyArrays: true },
185
- });
186
- }
152
+ const populateList = this._normalizePopulateInput(input);
153
+ const allowedPopulate =
154
+ securityConfig.accessLevels?.[this.userRole]?.allowedPopulate || [];
155
+
156
+ for (const populateItem of populateList) {
157
+ this._addPopulateStages({
158
+ model: this.model,
159
+ populateItem,
160
+ parentAlias: "",
161
+ allowedPopulate,
162
+ });
187
163
  }
188
164
 
189
165
  return this;
190
166
  }
191
167
 
192
- addManualFilters(filters) {
193
- if (filters) this.manualFilters = { ...this.manualFilters, ...filters };
194
- return this;
195
- }
196
-
197
168
  async execute(options = {}) {
198
169
  try {
199
170
  if (options.useCursor) this.useCursor = true;
200
- if (options.debug) logger.info("Pipeline:", this.pipeline);
201
- if (this.pipeline.length > (securityConfig.maxPipelineStages || 20)) {
171
+
172
+ if (options.debug) {
173
+ logger.info("Pipeline:", this.pipeline);
174
+ }
175
+
176
+ if (this.pipeline.length > (securityConfig.maxPipelineStages || 50)) {
202
177
  throw new HandleERROR("Too many pipeline stages", 400);
203
178
  }
204
179
 
205
- let agg = this.model
206
- .aggregate(this.pipeline)
207
- .option({ maxTimeMS: 10000 });
208
- const [cnt] = await this.model.aggregate([
209
- ...this.countPipeline,
180
+ const countPipeline = this._buildCountPipeline();
181
+
182
+ const [countResult] = await this.model.aggregate([
183
+ ...countPipeline,
210
184
  { $count: "total" },
211
185
  ]);
212
- const cursorOrData = this.useCursor
213
- ? agg.cursor({ batchSize: 100 }).exec()
214
- : agg
215
- .allowDiskUse(options.allowDiskUse || false)
216
- .readConcern("majority");
217
-
218
- const data = this.useCursor
219
- ? await cursorOrData.toArray()
220
- : await cursorOrData;
221
-
222
- const result = { success: true, count: cnt?.total || 0, data };
223
- if (options.projection) {
224
- result.data = result.data.map((doc) => {
225
- const projDoc = {};
226
- Object.keys(options.projection).forEach((f) => {
227
- if (options.projection[f]) projDoc[f] = doc[f];
228
- });
229
- return projDoc;
186
+
187
+ const aggregation = this.model
188
+ .aggregate(this.pipeline)
189
+ .option({ maxTimeMS: options.maxTimeMS || 10000 });
190
+
191
+ let data;
192
+
193
+ if (this.useCursor) {
194
+ const cursor = aggregation.cursor({
195
+ batchSize: options.batchSize || 100,
230
196
  });
197
+
198
+ data = [];
199
+
200
+ for await (const doc of cursor) {
201
+ data.push(doc);
202
+ }
203
+ } else {
204
+ data = await aggregation
205
+ .allowDiskUse(Boolean(options.allowDiskUse))
206
+ .readConcern(options.readConcern || "majority");
231
207
  }
232
- return result;
208
+
209
+ return {
210
+ success: true,
211
+ count: countResult?.total || 0,
212
+ data,
213
+ };
233
214
  } catch (err) {
234
215
  this._handleError(err);
235
216
  }
236
217
  }
237
218
 
238
- // ---------- Private Helpers ----------
239
-
240
219
  _sanitization() {
241
- // Remove unsafe ops
242
- ["$", "$where", "$accumulator", "$function"].forEach((op) => {
243
- delete this.query[op];
244
- });
245
- // Validate numeric
246
- ["page", "limit"].forEach((f) => {
247
- if (this.query[f] && !/^[0-9]+$/.test(this.query[f])) {
248
- throw new HandleERROR(`Invalid ${f}`, 400);
220
+ for (const key of Object.keys(this.query)) {
221
+ if (
222
+ key.startsWith("$") ||
223
+ ["$where", "$accumulator", "$function"].includes(key)
224
+ ) {
225
+ delete this.query[key];
226
+ }
227
+ }
228
+
229
+ ["page", "limit"].forEach((field) => {
230
+ if (this.query[field] && !/^[0-9]+$/.test(String(this.query[field]))) {
231
+ throw new HandleERROR(`Invalid ${field}`, 400);
249
232
  }
250
233
  });
251
234
  }
252
235
 
253
236
  _parseQueryFilters() {
254
237
  const obj = { ...this.query };
255
- ["page", "limit", "sort", "fields", "populate"].forEach(
256
- (k) => delete obj[k]
257
- );
238
+
239
+ RESERVED_QUERY_KEYS.forEach((key) => delete obj[key]);
258
240
 
259
241
  const out = {};
260
242
 
261
243
  for (const [rawKey, rawVal] of Object.entries(obj)) {
262
- if (typeof rawVal === "object" && !Array.isArray(rawVal)) {
263
- out[rawKey] = {};
264
- for (let [op, val] of Object.entries(rawVal)) {
244
+ const bracketMatch = rawKey.match(/^(.+)\[\$?(\w+)\]$/);
245
+
246
+ if (bracketMatch) {
247
+ const [, field, op] = bracketMatch;
248
+ const cleanOp = op.replace(/^\$/, "");
249
+
250
+ if (securityConfig.allowedOperators?.includes(cleanOp)) {
251
+ out[field] = {
252
+ ...(out[field] || {}),
253
+ [`$${cleanOp}`]: rawVal,
254
+ };
255
+ }
256
+
257
+ continue;
258
+ }
259
+
260
+ if (rawVal && typeof rawVal === "object" && !Array.isArray(rawVal)) {
261
+ out[rawKey] = out[rawKey] || {};
262
+
263
+ for (const [op, val] of Object.entries(rawVal)) {
265
264
  const cleanOp = op.replace(/^\$/, "");
266
- if (securityConfig.allowedOperators.includes(cleanOp)) {
267
- const v = /^[0-9]+$/.test(val) ? parseInt(val, 10) : val;
268
- out[rawKey][`$${cleanOp}`] = v;
265
+
266
+ if (securityConfig.allowedOperators?.includes(cleanOp)) {
267
+ out[rawKey][`$${cleanOp}`] = val;
269
268
  }
270
269
  }
271
- } else if (/^\w+\[\$?\w+\]$/.test(rawKey)) {
272
- const [, field, op] = rawKey.match(/^(\w+)\[\$?(\w+)\]$/);
273
- if (securityConfig.allowedOperators.includes(op)) {
274
- const v = /^[0-9]+$/.test(rawVal) ? parseInt(rawVal, 10) : rawVal;
275
- out[field] = { [`$${op}`]: v };
276
- }
270
+
271
+ continue;
272
+ }
273
+
274
+ if (typeof rawVal === "string" && rawVal.includes(",")) {
275
+ out[rawKey] = rawVal.split(",").map((v) => v.trim());
277
276
  } else {
278
- if (typeof rawVal === "string" && rawVal.includes(",")) {
279
- out[rawKey] = rawVal.split(",");
280
- } else {
281
- out[rawKey] = rawVal;
282
- }
277
+ out[rawKey] = rawVal;
283
278
  }
284
279
  }
285
280
 
286
281
  return out;
287
282
  }
288
283
 
289
- _sanitizeFilters(filters) {
290
- const resultObj = {};
291
- const resualt = Object.entries(filters).map((el) => {
292
- const [keyObj, val] = el;
293
- if (val === "null") {
294
- resultObj[keyObj] = null;
295
- return;
284
+ _sanitizeFilters(filters = {}) {
285
+ const sanitizeNode = (node, key = "") => {
286
+ if (node === null || node === "null") return null;
287
+ if (node === "true") return true;
288
+ if (node === "false") return false;
289
+
290
+ if (Array.isArray(node)) {
291
+ return node.map((item) => sanitizeNode(item, key));
296
292
  }
297
- if (
298
- typeof val === "object" &&
299
- (this.#isStrictObjectId(val["$eq"]) ||
300
- this.#isStrictObjectId(val["eq"]))
301
- ) {
302
- const newVal = { ...val };
303
- if (this.#isStrictObjectId(val["$eq"])) {
304
- newVal["$eq"] = new ObjectId(val["$eq"]);
305
- }
306
- if (this.#isStrictObjectId(val["eq"])) {
307
- newVal["eq"] = new ObjectId(val["eq"]);
293
+
294
+ if (node && typeof node === "object") {
295
+ const result = {};
296
+
297
+ for (const [childKey, childVal] of Object.entries(node)) {
298
+ result[childKey] = sanitizeNode(childVal, childKey);
308
299
  }
309
- resultObj[keyObj] = newVal;
310
- return;
300
+
301
+ return result;
311
302
  }
312
- if (val === "true") {
313
- resultObj[keyObj] = true;
314
- return;
303
+
304
+ if (typeof node === "string") {
305
+ if (this.#isStrictObjectId(node) && this._shouldConvertToObjectId(key)) {
306
+ return new ObjectId(node);
307
+ }
308
+
309
+ if (/^[0-9]+$/.test(node)) {
310
+ return node.length > 1 && node.startsWith("0")
311
+ ? node
312
+ : parseInt(node, 10);
313
+ }
315
314
  }
316
- if (val === "false") {
317
- resultObj[keyObj] = false;
318
- return;
315
+
316
+ return node;
317
+ };
318
+
319
+ return sanitizeNode(filters);
320
+ }
321
+
322
+ _shouldConvertToObjectId(key = "") {
323
+ const cleanKey = String(key).replace(/^\$/, "").toLowerCase();
324
+
325
+ return (
326
+ cleanKey === "_id" ||
327
+ cleanKey === "id" ||
328
+ cleanKey.endsWith("id") ||
329
+ cleanKey === "eq" ||
330
+ cleanKey === "ne" ||
331
+ cleanKey === "in" ||
332
+ cleanKey === "nin"
333
+ );
334
+ }
335
+
336
+ _deepMergeFilters(a = {}, b = {}) {
337
+ const out = { ...a };
338
+
339
+ for (const [key, value] of Object.entries(b)) {
340
+ if (
341
+ value &&
342
+ typeof value === "object" &&
343
+ !Array.isArray(value) &&
344
+ out[key] &&
345
+ typeof out[key] === "object" &&
346
+ !Array.isArray(out[key]) &&
347
+ !["$and", "$or", "$nor"].includes(key)
348
+ ) {
349
+ out[key] = this._deepMergeFilters(out[key], value);
350
+ } else {
351
+ out[key] = value;
319
352
  }
353
+ }
320
354
 
321
- if (typeof val === "string" && /^[0-9]+$/.test(val)) {
322
- if (val.length > 1 && val.startsWith("0")) {
323
- resultObj[keyObj] = val; // keep leading zero
355
+ return out;
356
+ }
357
+
358
+ _applySecurityFilters(filters = {}) {
359
+ const clean = { ...filters };
360
+
361
+ for (const field of securityConfig.forbiddenFields || []) {
362
+ delete clean[field];
363
+ }
364
+
365
+ return clean;
366
+ }
367
+
368
+ _normalizePopulateInput(input = "") {
369
+ const raw = [];
370
+
371
+ if (input) {
372
+ if (Array.isArray(input)) raw.push(...input);
373
+ else raw.push(input);
374
+ }
375
+
376
+ if (this.query.populate) {
377
+ raw.push(...String(this.query.populate).split(","));
378
+ }
379
+
380
+ const normalized = [];
381
+
382
+ const normalizeOne = (item) => {
383
+ if (!item) return;
384
+
385
+ if (typeof item === "string") {
386
+ const trimmed = item.trim();
387
+
388
+ if (!trimmed) return;
389
+
390
+ if (trimmed.includes(".")) {
391
+ const parts = trimmed.split(".").filter(Boolean);
392
+ const root = { path: parts[0] };
393
+ let current = root;
394
+
395
+ for (const part of parts.slice(1)) {
396
+ current.populate = { path: part };
397
+ current = current.populate;
398
+ }
399
+
400
+ normalized.push(root);
324
401
  } else {
325
- resultObj[keyObj] = parseInt(val, 10);
402
+ normalized.push({ path: trimmed });
326
403
  }
404
+
327
405
  return;
328
406
  }
329
407
 
330
- if (typeof val === "string" && this.#isStrictObjectId(val)) {
331
- resultObj[keyObj] = new ObjectId(val);
408
+ if (Array.isArray(item)) {
409
+ item.forEach(normalizeOne);
332
410
  return;
333
411
  }
334
- resultObj[keyObj] = val;
335
- });
336
- return resultObj;
412
+
413
+ if (item && typeof item === "object" && item.path) {
414
+ normalized.push(item);
415
+ }
416
+ };
417
+
418
+ raw.forEach(normalizeOne);
419
+
420
+ return this._dedupePopulate(normalized);
337
421
  }
338
422
 
339
- #isStrictObjectId(id) {
340
- return (
341
- typeof id === "string" &&
342
- mongoose.Types.ObjectId.isValid(id) &&
343
- new mongoose.Types.ObjectId(id).toString() === id
344
- );
423
+ _dedupePopulate(items) {
424
+ const map = new Map();
425
+
426
+ for (const item of items) {
427
+ map.set(item.path, item);
428
+ }
429
+
430
+ return [...map.values()];
345
431
  }
346
432
 
347
- _applySecurityFilters(filters) {
348
- let res = { ...filters };
349
- securityConfig.forbiddenFields.forEach((f) => delete res[f]);
350
- return res;
433
+ _addPopulateStages({ model, populateItem, parentAlias, allowedPopulate }) {
434
+ const path = populateItem.path;
435
+ const fullPath = parentAlias ? `${parentAlias}.${path}` : path;
436
+
437
+ if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
438
+ return;
439
+ }
440
+
441
+ const info = this._getPopulateInfo(model, path, parentAlias);
442
+
443
+ this.pipeline.push({
444
+ $lookup: {
445
+ from: info.collection,
446
+ localField: info.localField,
447
+ foreignField: "_id",
448
+ as: info.as,
449
+ },
450
+ });
451
+
452
+ if (!info.isArray) {
453
+ this.pipeline.push({
454
+ $unwind: {
455
+ path: `$${info.as}`,
456
+ preserveNullAndEmptyArrays: true,
457
+ },
458
+ });
459
+ }
460
+
461
+ if (populateItem.select) {
462
+ const project = this._buildPopulateProjection(populateItem.select, info.as);
463
+
464
+ if (Object.keys(project).length) {
465
+ this.pipeline.push({ $project: project });
466
+ }
467
+ }
468
+
469
+ if (populateItem.populate) {
470
+ const nestedList = this._normalizeNestedPopulate(populateItem.populate);
471
+
472
+ for (const nested of nestedList) {
473
+ this._addPopulateStages({
474
+ model: info.refModel,
475
+ populateItem: nested,
476
+ parentAlias: info.as,
477
+ allowedPopulate,
478
+ });
479
+ }
480
+ }
351
481
  }
352
482
 
353
- _getCollectionInfo(field) {
354
- const path = this.model.schema.path(field);
355
- if (!path?.options?.ref && !path?.options?.type[0]?.ref)
356
- throw new HandleERROR(`Invalid populate: ${field}`, 400);
483
+ _normalizeNestedPopulate(input) {
484
+ if (!input) return [];
485
+
486
+ if (Array.isArray(input)) {
487
+ return input.flatMap((item) => this._normalizeNestedPopulate(item));
488
+ }
489
+
490
+ if (typeof input === "string") {
491
+ return this._normalizePopulateInput(input);
492
+ }
493
+
494
+ if (typeof input === "object" && input.path) {
495
+ return [input];
496
+ }
497
+
498
+ return [];
499
+ }
500
+
501
+ _getPopulateInfo(model, path, parentAlias = "") {
502
+ const schemaPath = model.schema.path(path);
503
+
504
+ if (!schemaPath) {
505
+ throw new HandleERROR(`Invalid populate path: ${path}`, 400);
506
+ }
507
+
508
+ const isArray = schemaPath.instance === "Array";
357
509
 
358
510
  const refModelName =
359
- path?.options?.ref?.toLowerCase() ||
360
- path?.options?.type[0]?.ref.toLowerCase();
511
+ schemaPath.options?.ref ||
512
+ schemaPath.caster?.options?.ref ||
513
+ (Array.isArray(schemaPath.options?.type)
514
+ ? schemaPath.options.type[0]?.ref
515
+ : undefined);
516
+
517
+ if (!refModelName) {
518
+ throw new HandleERROR(`Populate path has no ref: ${path}`, 400);
519
+ }
361
520
 
362
- const collectionName = pluralize(refModelName);
521
+ const refModel = this._resolveModel(refModelName, model);
522
+
523
+ const as = parentAlias ? `${parentAlias}.${path}` : path;
363
524
 
364
525
  return {
365
- collection: collectionName,
366
- isArray: path.instance === "Array",
526
+ refModel,
527
+ collection: this._resolveCollectionName(refModelName, refModel),
528
+ localField: as,
529
+ foreignField: "_id",
530
+ as,
531
+ isArray,
367
532
  };
368
533
  }
369
534
 
535
+ _resolveModel(refModelName, currentModel) {
536
+ const connection = currentModel?.db || this.model?.db || mongoose.connection;
537
+
538
+ return connection.models?.[refModelName] || mongoose.models?.[refModelName] || null;
539
+ }
540
+
541
+ _resolveCollectionName(refModelName, refModel) {
542
+ if (refModel?.collection?.name) {
543
+ return refModel.collection.name;
544
+ }
545
+
546
+ return pluralize(String(refModelName).toLowerCase());
547
+ }
548
+
549
+ _buildPopulateProjection(select, alias) {
550
+ const fields = String(select)
551
+ .split(" ")
552
+ .map((field) => field.trim())
553
+ .filter(Boolean);
554
+
555
+ const hasInclude = fields.some((field) => !field.startsWith("-"));
556
+ const hasExclude = fields.some((field) => field.startsWith("-"));
557
+
558
+ if (hasInclude && hasExclude) {
559
+ throw new HandleERROR(
560
+ "Cannot mix include and exclude in populate select",
561
+ 400
562
+ );
563
+ }
564
+
565
+ const project = {};
566
+
567
+ for (const field of fields) {
568
+ const cleanField = field.replace(/^-/, "");
569
+
570
+ if (this._isForbiddenField(cleanField)) continue;
571
+
572
+ project[`${alias}.${cleanField}`] = field.startsWith("-") ? 0 : 1;
573
+ }
574
+
575
+ return project;
576
+ }
577
+
578
+ _isPopulateAllowed(path, allowedPopulate = []) {
579
+ return (
580
+ allowedPopulate.includes("*") ||
581
+ allowedPopulate.includes(path) ||
582
+ allowedPopulate.includes(path.split(".")[0])
583
+ );
584
+ }
585
+
586
+ _isForbiddenField(field) {
587
+ return (securityConfig.forbiddenFields || []).includes(field);
588
+ }
589
+
590
+ _buildCountPipeline() {
591
+ return this.pipeline.filter((stage) => {
592
+ return !(
593
+ "$skip" in stage ||
594
+ "$limit" in stage ||
595
+ "$sort" in stage ||
596
+ "$project" in stage
597
+ );
598
+ });
599
+ }
600
+
601
+ _escapeRegex(value) {
602
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
603
+ }
604
+
605
+ #isStrictObjectId(id) {
606
+ return (
607
+ typeof id === "string" &&
608
+ mongoose.Types.ObjectId.isValid(id) &&
609
+ new mongoose.Types.ObjectId(id).toString() === id
610
+ );
611
+ }
612
+
370
613
  _handleError(err) {
371
614
  logger.error(`[ApiFeatures] ${err.message}`, { stack: err.stack });
372
615
  throw err;
373
616
  }
374
617
  }
375
618
 
376
- export default ApiFeatures;
619
+ export default ApiFeatures;