vanta-api 1.3.1 → 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 +2 -2
  3. package/src/api-features.js +492 -241
@@ -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,355 +14,606 @@ 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 (!userRole || ! Object.keys(securityConfig.accessLevels).includes(userRole)) {
22
- this.userRole = 'guest';
23
- } else {
24
- this.userRole = userRole;
25
- }
26
-
27
23
  this.pipeline = [];
28
- this.countPipeline = [];
29
24
  this.manualFilters = {};
30
25
  this.useCursor = false;
31
26
 
27
+ this.userRole =
28
+ userRole && securityConfig.accessLevels?.[userRole] ? userRole : "guest";
29
+
32
30
  this._sanitization();
33
31
  }
34
32
 
35
- // ---------- Core Methods ----------
36
-
37
33
  filter() {
38
- // Parse and sanitize both query and manual filters
39
34
  const queryFilters = this._parseQueryFilters();
40
- const merged = this._sanitizeFilters({
41
- ...queryFilters,
42
- ...this.manualFilters,
43
- });
44
- 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);
45
38
 
46
- if (Object.keys(safe).length) {
47
- this.pipeline.push({ $match: safe });
48
- this.countPipeline.push({ $match: safe });
39
+ if (Object.keys(safeFilters).length) {
40
+ this.pipeline.push({ $match: safeFilters });
49
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
+
50
73
  return this;
51
74
  }
52
75
 
53
76
  sort() {
54
77
  if (!this.query.sort) return this;
55
- const parts = this.query.sort.split(",");
56
- const validFields = Object.keys(this.model.schema.paths);
78
+
57
79
  const sortObj = {};
80
+ const validFields = new Set(Object.keys(this.model.schema.paths));
81
+
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
+ });
58
94
 
59
- for (const part of parts) {
60
- const dir = part.startsWith("-") ? -1 : 1;
61
- const key = part.replace(/^[-+]/, "");
62
- if (validFields.includes(key)) sortObj[key] = dir;
95
+ if (Object.keys(sortObj).length) {
96
+ this.pipeline.push({ $sort: sortObj });
63
97
  }
64
98
 
65
- if (Object.keys(sortObj).length) this.pipeline.push({ $sort: sortObj });
66
99
  return this;
67
100
  }
68
101
 
69
- limitFields(input = "") {
70
- const rawFields = [input, this.query.fields].filter(Boolean).join(",");
71
- if (!rawFields) return this;
72
-
73
- const validFields = Object.keys(this.model.schema.paths).filter(
74
- (f) => !securityConfig.forbiddenFields.includes(f)
75
- );
76
-
77
- const fieldsArray = rawFields
78
- .split(",")
79
- .map((f) => f.trim())
80
- .filter(Boolean);
81
-
82
- const includeFields = new Set();
83
- const excludeFields = new Set();
84
-
85
- fieldsArray.forEach((f) => {
86
- if (f.startsWith("-")) excludeFields.add(f.slice(1));
87
- else includeFields.add(f);
88
- });
89
- const project = {};
90
- if (includeFields.size > 0) {
91
- includeFields.forEach((f) => {
92
- if (validFields.includes(f)) project[f] = 1;
93
- });
94
- } else if (excludeFields.size > 0) {
95
- validFields.forEach((f) => {
96
- if (!excludeFields.has(f)) project[f] = 1;
97
- });
98
- }
102
+ limitFields(input = "") {
103
+ const rawFields = [input, this.query.fields].filter(Boolean).join(",");
104
+
105
+ if (!rawFields) return this;
106
+
107
+ const fields = rawFields
108
+ .split(",")
109
+ .map((field) => field.trim())
110
+ .filter(Boolean);
99
111
 
100
- if (Object.keys(project).length) {
101
- this.pipeline.push({ $project: project });
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
+ }
118
+
119
+ const project = {};
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;
127
+ }
128
+
129
+ if (Object.keys(project).length) {
130
+ this.pipeline.push({ $project: project });
131
+ }
132
+
133
+ return this;
102
134
  }
103
135
 
104
- return this;
105
- }
106
136
  paginate() {
107
- const { maxLimit } = securityConfig.accessLevels[this.userRole] || {
108
- maxLimit: 100,
109
- };
137
+ const access = securityConfig.accessLevels?.[this.userRole] || {};
138
+ const maxLimit = access.maxLimit || 100;
139
+
110
140
  const page = Math.max(parseInt(this.query.page, 10) || 1, 1);
111
- const lim = Math.min(
141
+ const limit = Math.min(
112
142
  Math.max(parseInt(this.query.limit, 10) || 10, 1),
113
143
  maxLimit
114
144
  );
115
145
 
116
- this.pipeline.push({ $skip: (page - 1) * lim }, { $limit: lim });
146
+ this.pipeline.push({ $skip: (page - 1) * limit }, { $limit: limit });
147
+
117
148
  return this;
118
149
  }
119
150
 
120
151
  populate(input = "") {
121
- let list = [];
122
- const raw = Array.isArray(input) ? input : [input];
123
- if (this.query.populate) raw.push(...this.query.populate.split(","));
124
-
125
- raw.forEach((item) => {
126
- if (typeof item === "string" && item.trim()) list.push(item.trim());
127
- else if (item?.path) list.push(item);
128
- });
129
-
130
- const map = new Map();
131
- list.forEach((opt) => {
132
- const key = typeof opt === "string" ? opt : opt.path;
133
- map.set(key, opt);
134
- });
135
-
136
- const allowed =
137
- securityConfig.accessLevels[this.userRole]?.allowedPopulate || [];
138
- const final = [];
139
- map.forEach((opt, key) => {
140
- if (allowed.includes("*") || allowed.includes(key)) final.push(opt);
141
- });
142
-
143
- for (const opt of final) {
144
- const field = typeof opt === "string" ? opt : opt.path;
145
- const proj =
146
- typeof opt === "object" && opt.select
147
- ? opt.select.split(" ").reduce(
148
- (a, f) => {
149
- a[f] = 1;
150
- return a;
151
- },
152
- { _id: 1 }
153
- )
154
- : {};
155
-
156
- const { collection, isArray } = this._getCollectionInfo(field);
157
-
158
- const matchStage = isArray
159
- ? { $match: { $expr: { $in: ["$_id", "$$id"] } } }
160
- : { $match: { $expr: { $eq: ["$_id", "$$id"] } } };
161
-
162
- const lookup =
163
- proj && Object.keys(proj).length
164
- ? {
165
- from: collection,
166
- let: { id: `$${field}` },
167
- pipeline: [matchStage, { $project: proj }],
168
- as: field,
169
- }
170
- : {
171
- from: collection,
172
- localField: field,
173
- foreignField: "_id",
174
- as: field,
175
- };
176
-
177
- this.pipeline.push({ $lookup: lookup });
178
-
179
- if (!isArray) {
180
- this.pipeline.push({
181
- $unwind: { path: `$${field}`, preserveNullAndEmptyArrays: true },
182
- });
183
- }
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
+ });
184
163
  }
185
164
 
186
165
  return this;
187
166
  }
188
167
 
189
- addManualFilters(filters) {
190
- if (filters) this.manualFilters = { ...this.manualFilters, ...filters };
191
- return this;
192
- }
193
-
194
168
  async execute(options = {}) {
195
169
  try {
196
170
  if (options.useCursor) this.useCursor = true;
197
- if (options.debug) logger.info("Pipeline:", this.pipeline);
198
- 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)) {
199
177
  throw new HandleERROR("Too many pipeline stages", 400);
200
178
  }
201
179
 
202
- let agg = this.model
203
- .aggregate(this.pipeline)
204
- .option({ maxTimeMS: 10000 });
205
- const [cnt] = await this.model.aggregate([
206
- ...this.countPipeline,
180
+ const countPipeline = this._buildCountPipeline();
181
+
182
+ const [countResult] = await this.model.aggregate([
183
+ ...countPipeline,
207
184
  { $count: "total" },
208
185
  ]);
209
- const cursorOrData = this.useCursor
210
- ? agg.cursor({ batchSize: 100 }).exec()
211
- : agg
212
- .allowDiskUse(options.allowDiskUse || false)
213
- .readConcern("majority");
214
-
215
- const data = this.useCursor
216
- ? await cursorOrData.toArray()
217
- : await cursorOrData;
218
-
219
- const result = { success: true, count: cnt?.total || 0, data };
220
- if (options.projection) {
221
- result.data = result.data.map((doc) => {
222
- const projDoc = {};
223
- Object.keys(options.projection).forEach((f) => {
224
- if (options.projection[f]) projDoc[f] = doc[f];
225
- });
226
- 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,
227
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");
228
207
  }
229
- return result;
208
+
209
+ return {
210
+ success: true,
211
+ count: countResult?.total || 0,
212
+ data,
213
+ };
230
214
  } catch (err) {
231
215
  this._handleError(err);
232
216
  }
233
217
  }
234
218
 
235
- // ---------- Private Helpers ----------
236
-
237
219
  _sanitization() {
238
- // Remove unsafe ops
239
- ["$", "$where", "$accumulator", "$function"].forEach((op) => {
240
- delete this.query[op];
241
- });
242
- // Validate numeric
243
- ["page", "limit"].forEach((f) => {
244
- if (this.query[f] && !/^[0-9]+$/.test(this.query[f])) {
245
- 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);
246
232
  }
247
233
  });
248
234
  }
249
235
 
250
236
  _parseQueryFilters() {
251
237
  const obj = { ...this.query };
252
- ["page", "limit", "sort", "fields", "populate"].forEach(
253
- (k) => delete obj[k]
254
- );
238
+
239
+ RESERVED_QUERY_KEYS.forEach((key) => delete obj[key]);
255
240
 
256
241
  const out = {};
257
242
 
258
243
  for (const [rawKey, rawVal] of Object.entries(obj)) {
259
- if (typeof rawVal === "object" && !Array.isArray(rawVal)) {
260
- out[rawKey] = {};
261
- 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)) {
262
264
  const cleanOp = op.replace(/^\$/, "");
263
- if (securityConfig.allowedOperators.includes(cleanOp)) {
264
- const v = /^[0-9]+$/.test(val) ? parseInt(val, 10) : val;
265
- out[rawKey][`$${cleanOp}`] = v;
265
+
266
+ if (securityConfig.allowedOperators?.includes(cleanOp)) {
267
+ out[rawKey][`$${cleanOp}`] = val;
266
268
  }
267
269
  }
268
- } else if (/^\w+\[\$?\w+\]$/.test(rawKey)) {
269
- const [, field, op] = rawKey.match(/^(\w+)\[\$?(\w+)\]$/);
270
- if (securityConfig.allowedOperators.includes(op)) {
271
- const v = /^[0-9]+$/.test(rawVal) ? parseInt(rawVal, 10) : rawVal;
272
- out[field] = { [`$${op}`]: v };
273
- }
270
+
271
+ continue;
272
+ }
273
+
274
+ if (typeof rawVal === "string" && rawVal.includes(",")) {
275
+ out[rawKey] = rawVal.split(",").map((v) => v.trim());
274
276
  } else {
275
- if (typeof rawVal === "string" && rawVal.includes(",")) {
276
- out[rawKey] = rawVal.split(",");
277
- } else {
278
- out[rawKey] = rawVal;
279
- }
277
+ out[rawKey] = rawVal;
280
278
  }
281
279
  }
282
280
 
283
281
  return out;
284
282
  }
285
283
 
286
- _sanitizeFilters(filters) {
287
- const resultObj = {};
288
- const resualt = Object.entries(filters).map((el) => {
289
- const [keyObj, val] = el;
290
- if (val === "null") {
291
- resultObj[keyObj] = null;
292
- 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));
293
292
  }
294
- if (
295
- typeof val === "object" &&
296
- (this.#isStrictObjectId(val["$eq"]) ||
297
- this.#isStrictObjectId(val["eq"]))
298
- ) {
299
- const newVal = { ...val };
300
- if (this.#isStrictObjectId(val["$eq"])) {
301
- 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);
299
+ }
300
+
301
+ return result;
302
+ }
303
+
304
+ if (typeof node === "string") {
305
+ if (this.#isStrictObjectId(node) && this._shouldConvertToObjectId(key)) {
306
+ return new ObjectId(node);
302
307
  }
303
- if (this.#isStrictObjectId(val["eq"])) {
304
- newVal["eq"] = new ObjectId(val["eq"]);
308
+
309
+ if (/^[0-9]+$/.test(node)) {
310
+ return node.length > 1 && node.startsWith("0")
311
+ ? node
312
+ : parseInt(node, 10);
305
313
  }
306
- resultObj[keyObj] = newVal;
307
- return;
308
314
  }
309
- if (val === "true") {
310
- resultObj[keyObj] = true;
311
- 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;
312
352
  }
313
- if (val === "false") {
314
- resultObj[keyObj] = false;
353
+ }
354
+
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);
401
+ } else {
402
+ normalized.push({ path: trimmed });
403
+ }
404
+
315
405
  return;
316
406
  }
317
407
 
318
- if (typeof val === "string" && /^[0-9]+$/.test(val)) {
319
- resultObj[keyObj] = parseInt(val, 10);
408
+ if (Array.isArray(item)) {
409
+ item.forEach(normalizeOne);
320
410
  return;
321
411
  }
322
- if (typeof val === "string" && this.#isStrictObjectId(val)) {
323
- resultObj[keyObj] = new ObjectId(val);
324
- return;
412
+
413
+ if (item && typeof item === "object" && item.path) {
414
+ normalized.push(item);
325
415
  }
326
- resultObj[keyObj] = val;
327
- });
328
- return resultObj;
416
+ };
417
+
418
+ raw.forEach(normalizeOne);
419
+
420
+ return this._dedupePopulate(normalized);
329
421
  }
330
422
 
331
- #isStrictObjectId(id) {
332
- return (
333
- typeof id === "string" &&
334
- mongoose.Types.ObjectId.isValid(id) &&
335
- new mongoose.Types.ObjectId(id).toString() === id
336
- );
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()];
431
+ }
432
+
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
+ }
337
481
  }
338
482
 
339
- _applySecurityFilters(filters) {
340
- let res = { ...filters };
341
- securityConfig.forbiddenFields.forEach((f) => delete res[f]);
342
- return res;
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 [];
343
499
  }
344
500
 
345
- _getCollectionInfo(field) {
346
- const path = this.model.schema.path(field);
347
- if (!path?.options?.ref && !path?.options?.type[0]?.ref)
348
- throw new HandleERROR(`Invalid populate: ${field}`, 400);
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";
349
509
 
350
510
  const refModelName =
351
- path?.options?.ref?.toLowerCase() ||
352
- 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
+ }
353
520
 
354
- const collectionName = pluralize(refModelName);
521
+ const refModel = this._resolveModel(refModelName, model);
522
+
523
+ const as = parentAlias ? `${parentAlias}.${path}` : path;
355
524
 
356
525
  return {
357
- collection: collectionName,
358
- isArray: path.instance === "Array",
526
+ refModel,
527
+ collection: this._resolveCollectionName(refModelName, refModel),
528
+ localField: as,
529
+ foreignField: "_id",
530
+ as,
531
+ isArray,
359
532
  };
360
533
  }
361
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
+
362
613
  _handleError(err) {
363
614
  logger.error(`[ApiFeatures] ${err.message}`, { stack: err.stack });
364
615
  throw err;
365
616
  }
366
617
  }
367
618
 
368
- export default ApiFeatures;
619
+ export default ApiFeatures;