• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mia-platform / crud-service / 13717074317

07 Mar 2025 08:50AM UTC coverage: 97.386% (+0.2%) from 97.209%
13717074317

push

github

web-flow
fix: overhaul /export endpoint to prevent memory leak (#467)

* refactor: review code

* refactor: review list and export endpoint logic

* fix: prevent cursors memory leak when request cannot be served

* 7.2.3-rc.2

* refactor: replace JSON serialization with object conversion

* refactor: cleanup configuration option and update CHANGELOG.md

* 7.2.3-rc.3

* chore(deps): update mongodb to fix kms error

* 7.2.3-rc.4

1448 of 1556 branches covered (93.06%)

Branch coverage included in aggregate %.

241 of 260 new or added lines in 9 files covered. (92.69%)

2 existing lines in 1 file now uncovered.

9392 of 9575 relevant lines covered (98.09%)

7883.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.76
/lib/httpInterface.js
1
/*
93✔
2
 * Copyright 2023 Mia s.r.l.
93✔
3
 *
93✔
4
 * Licensed under the Apache License, Version 2.0 (the "License");
93✔
5
 * you may not use this file except in compliance with the License.
93✔
6
 * You may obtain a copy of the License at
93✔
7
 *
93✔
8
 *     http://www.apache.org/licenses/LICENSE-2.0
93✔
9
 *
93✔
10
 * Unless required by applicable law or agreed to in writing, software
93✔
11
 * distributed under the License is distributed on an "AS IS" BASIS,
93✔
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
93✔
13
 * See the License for the specific language governing permissions and
93✔
14
 * limitations under the License.
93✔
15
 */
93✔
16

93✔
17
/* eslint-disable max-lines */
93✔
18

93✔
19
'use strict'
93✔
20

93✔
21
const { pipeline } = require('stream/promises')
93✔
22
const { isEmpty: lisEmpty } = require('lodash')
93✔
23
const through2 = require('through2')
93✔
24

93✔
25
const {
93✔
26
  SORT,
93✔
27
  PROJECTION,
93✔
28
  RAW_PROJECTION,
93✔
29
  EXPORT_OPTIONS,
93✔
30
  QUERY,
93✔
31
  LIMIT,
93✔
32
  SKIP,
93✔
33
  STATE,
93✔
34
  UPDATERID,
93✔
35
  UPDATEDAT,
93✔
36
  CREATORID,
93✔
37
  CREATEDAT,
93✔
38
  __STATE__,
93✔
39
  rawProjectionDictionary,
93✔
40
  USE_ESTIMATE,
93✔
41
  BAD_REQUEST_ERROR_STATUS_CODE,
93✔
42
  INTERNAL_SERVER_ERROR_STATUS_CODE,
93✔
43
  UNIQUE_INDEX_ERROR_STATUS_CODE,
93✔
44
  NOT_ACCEPTABLE,
93✔
45
  UNSUPPORTED_MIME_TYPE_STATUS_CODE,
93✔
46
  ACL_WRITE_COLUMNS,
93✔
47
  ACL_ROWS,
93✔
48
} = require('./consts')
93✔
49

93✔
50
const { getReplyTypeCallback } = require('./acceptHeaderParser')
93✔
51
const BadRequestError = require('./BadRequestError')
93✔
52
const BatchWritableStream = require('./BatchWritableStream')
93✔
53

93✔
54
const resolveMongoQuery = require('./resolveMongoQuery')
93✔
55
const { getAjvResponseValidationFunction, shouldValidateStream, shouldValidateItem } = require('./validatorGetters')
93✔
56
const { getFileMimeParser, getFileMimeStringifiers } = require('./mimeTypeTransform')
93✔
57
const { addValidatorCompiler } = require('./compilers')
93✔
58
const { castItem, castCollectionId } = require('./AdditionalCaster')
93✔
59

93✔
60
const OPTIONS_INCOMPATIBILITY_ERROR_CODE = 2
93✔
61
const UNIQUE_INDEX_MONGO_ERROR_CODE = 11000
93✔
62

93✔
63
const PROMETHEUS_OP_TYPE = {
93✔
64
  FETCH: 'fetch',
93✔
65
  INSERT_OR_UPDATE: 'insert_or_update',
93✔
66
  DELETE: 'delete',
93✔
67
  CHANGE_STATE: 'change_state',
93✔
68
}
93✔
69

93✔
70

93✔
71
// eslint-disable-next-line max-statements
93✔
72
module.exports = async function getHttpInterface(fastify, options) {
93✔
73
  if (!fastify.crudService) { throw new Error('`fastify.crudService` is undefined') }
4,440!
74
  if (!fastify.queryParser) { throw new Error('`fastify.queryParser` is undefined') }
4,440!
75
  if (!fastify.allFieldNames) { throw new Error('`fastify.allFieldNames` is undefined') }
4,440!
76
  if (!fastify.jsonSchemaGenerator) { throw new Error('`fastify.jsonSchemaGenerator` is undefined') }
4,440!
77
  if (!fastify.jsonSchemaGeneratorWithNested) { throw new Error('`fastify.jsonSchemaGeneratorWithNested` is undefined') }
4,440!
78
  if (!fastify.userIdHeaderKey) { throw new Error('`fastify.userIdHeaderKey` is undefined') }
4,440!
79
  if (!fastify.modelName) { throw new Error('`fastify.modelName` is undefined') }
4,440!
80

4,440✔
81
  const {
4,440✔
82
    registerGetters = true,
4,440✔
83
    registerSetters = true,
4,440✔
84
    registerLookup = false,
4,440✔
85
  } = options
4,440✔
86

4,440✔
87
  const validateOutput = fastify.validateOutput ?? false
4,440!
88

4,440✔
89

4,440✔
90
  addValidatorCompiler(fastify, fastify.models, { HELPERS_PREFIX: fastify.config.HELPERS_PREFIX })
4,440✔
91

4,440✔
92
  if (registerSetters) {
4,440✔
93
    fastify.post(
3,795✔
94
      '/',
3,795✔
95
      { schema: fastify.jsonSchemaGenerator.generatePostJSONSchema() },
3,795✔
96
      handleInsertOne
3,795✔
97
    )
3,795✔
98
    fastify.post(
3,795✔
99
      '/validate',
3,795✔
100
      { schema: fastify.jsonSchemaGenerator.generateValidateJSONSchema() },
3,795✔
101
      handleValidate
3,795✔
102
    )
3,795✔
103
    fastify.delete(
3,795✔
104
      '/:id',
3,795✔
105
      { schema: fastify.jsonSchemaGenerator.generateDeleteJSONSchema() },
3,795✔
106
      handleDeleteId
3,795✔
107
    )
3,795✔
108
    fastify.delete(
3,795✔
109
      '/',
3,795✔
110
      { schema: fastify.jsonSchemaGenerator.generateDeleteListJSONSchema() },
3,795✔
111
      handleDeleteList
3,795✔
112
    )
3,795✔
113

3,795✔
114
    const patchIdSchema = fastify.jsonSchemaGenerator.generatePatchJSONSchema()
3,795✔
115
    fastify.patch(
3,795✔
116
      '/:id',
3,795✔
117
      {
3,795✔
118
        schema: patchIdSchema,
3,795✔
119
      },
3,795✔
120
      handlePatchId
3,795✔
121
    )
3,795✔
122
    fastify.patch(
3,795✔
123
      '/',
3,795✔
124
      { schema: fastify.jsonSchemaGenerator.generatePatchManyJSONSchema() },
3,795✔
125
      handlePatchMany
3,795✔
126
    )
3,795✔
127

3,795✔
128
    const upsertOneSchema = fastify.jsonSchemaGenerator.generateUpsertOneJSONSchema()
3,795✔
129
    fastify.post(
3,795✔
130
      '/upsert-one', {
3,795✔
131
        schema: upsertOneSchema,
3,795✔
132
        config: {
3,795✔
133
          itemValidator: shouldValidateItem(upsertOneSchema.response['200'], validateOutput),
3,795✔
134
        },
3,795✔
135
      },
3,795✔
136
      handleUpsertOne
3,795✔
137
    )
3,795✔
138

3,795✔
139
    fastify.post('/bulk', {
3,795✔
140
      schema: fastify.jsonSchemaGenerator.generateBulkJSONSchema(),
3,795✔
141
    }, handleInsertMany)
3,795✔
142
    fastify.patch('/bulk', {
3,795✔
143
      schema: fastify.jsonSchemaGenerator.generatePatchBulkJSONSchema(),
3,795✔
144
    }, handlePatchBulk)
3,795✔
145
    fastify.post(
3,795✔
146
      '/:id/state',
3,795✔
147
      { schema: fastify.jsonSchemaGenerator.generateChangeStateJSONSchema() },
3,795✔
148
      handleChangeStateById
3,795✔
149
    )
3,795✔
150
    fastify.post(
3,795✔
151
      '/state',
3,795✔
152
      { schema: fastify.jsonSchemaGenerator.generateChangeStateManyJSONSchema() },
3,795✔
153
      handleChangeStateMany
3,795✔
154
    )
3,795✔
155

3,795✔
156
    const importPostSchema = fastify.jsonSchemaGenerator.generatePostImportJSONSchema()
3,795✔
157
    fastify.post(
3,795✔
158
      '/import',
3,795✔
159
      {
3,795✔
160
        schema: importPostSchema,
3,795✔
161
        config: {
3,795✔
162
          itemValidator: getAjvResponseValidationFunction(importPostSchema.streamBody),
3,795✔
163
          validateImportOptions: getAjvResponseValidationFunction(importPostSchema.optionSchema,
3,795✔
164
            true
3,795✔
165
          ),
3,795✔
166
        },
3,795✔
167
      },
3,795✔
168
      handleCollectionImport
3,795✔
169
    )
3,795✔
170

3,795✔
171
    const importPatchSchema = fastify.jsonSchemaGenerator.generatePatchImportJSONSchema()
3,795✔
172
    fastify.patch(
3,795✔
173
      '/import',
3,795✔
174
      {
3,795✔
175
        schema: importPatchSchema,
3,795✔
176
        config: {
3,795✔
177
          itemValidator: getAjvResponseValidationFunction(importPatchSchema.streamBody),
3,795✔
178
          validateImportOptions: getAjvResponseValidationFunction(importPatchSchema.optionSchema,
3,795✔
179
            true
3,795✔
180
          ),
3,795✔
181
        },
3,795✔
182
      },
3,795✔
183
      handleCollectionImport
3,795✔
184
    )
3,795✔
185

3,795✔
186
    fastify.log.debug({ collection: fastify?.modelName }, 'setters endpoints registered')
3,795✔
187
  }
3,795✔
188

4,440✔
189
  if (registerLookup) {
4,440✔
190
    if (!fastify.lookupProjection) { throw new Error('`fastify.lookupProjection` is undefined') }
18!
191
    const listLookupSchema = fastify.jsonSchemaGenerator.generateGetListLookupJSONSchema()
18✔
192
    fastify.get('/', {
18✔
193
      schema: listLookupSchema,
18✔
194
      config: {
18✔
195
        streamValidator: shouldValidateStream(listLookupSchema.response['200'], validateOutput),
18✔
196
        replyType: () => 'application/json',
18✔
197
      },
18✔
198
    }, handleGetListLookup)
18✔
199
    fastify.log.debug({ collection: fastify?.modelName }, 'lookup endpoint registered')
18✔
200
  }
18✔
201

4,440✔
202
  if (registerGetters) {
4,440✔
203
    const getItemJSONSchemaWithoutRequired = fastify.jsonSchemaGenerator.generateGetItemJSONSchema()
4,404✔
204
    const getItemJSONSchemaWithRequired = fastify.jsonSchemaGenerator.generateGetItemJSONSchema(true)
4,404✔
205
    const defaultAccept = 'application/x-ndjson'
4,404✔
206

4,404✔
207
    fastify.get('/export', {
4,404✔
208
      schema: fastify.jsonSchemaGenerator.generateExportJSONSchema(defaultAccept),
4,404✔
209
      config: {
4,404✔
210
        streamValidator: shouldValidateStream(getItemJSONSchemaWithoutRequired.response['200'], validateOutput),
4,404✔
211
        replyType: getReplyTypeCallback(defaultAccept),
4,404✔
212
      },
4,404✔
213
    }, handleGetList)
4,404✔
214
    fastify.get('/count', { schema: fastify.jsonSchemaGenerator.generateCountJSONSchema() }, handleCount)
4,404✔
215
    fastify.get(
4,404✔
216
      '/schema',
4,404✔
217
      {
4,404✔
218
        schema: fastify.jsonSchemaGenerator.generateGetSchemaJSONSchema(),
4,404✔
219
      },
4,404✔
220
      () => ({
4,404✔
221
        type: getItemJSONSchemaWithRequired.response['200'].type,
3✔
222
        properties: getItemJSONSchemaWithRequired.response['200'].properties,
3✔
223
        required: getItemJSONSchemaWithRequired.response['200'].required,
3✔
224
      })
3✔
225
    )
4,404✔
226

4,404✔
227
    fastify.get('/', {
4,404✔
228
      schema: fastify.jsonSchemaGenerator.generateGetListJSONSchema(),
4,404✔
229
      config: {
4,404✔
230
        streamValidator: shouldValidateStream(getItemJSONSchemaWithoutRequired.response['200'], validateOutput),
4,404✔
231
        replyType: () => 'application/json',
4,404✔
232
      },
4,404✔
233
    }, handleGetList)
4,404✔
234
    fastify.get('/:id', {
4,404✔
235
      schema: getItemJSONSchemaWithoutRequired,
4,404✔
236
      config: {
4,404✔
237
        itemValidator: shouldValidateItem(getItemJSONSchemaWithoutRequired.response['200'], validateOutput),
4,404✔
238
      },
4,404✔
239
    }, handleGetId)
4,404✔
240

4,404✔
241
    fastify.log.debug({ collection: fastify?.modelName }, 'getters endpoints registered')
4,404✔
242
  }
4,404✔
243
}
4,440✔
244

93✔
245
// eslint-disable-next-line max-statements
93✔
246
async function handleCollectionImport(request, reply) {
72✔
247
  this.customMetrics?.collectionInvocation?.inc({
72!
248
    collection_name: this.modelName,
72✔
249
    type: PROMETHEUS_OP_TYPE.IMPORT,
72✔
250
  })
72✔
251

72✔
252
  if (!request.isMultipart()) {
72!
253
    return reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, 'Request is not multipart')
×
254
  }
×
255

72✔
256
  const data = await request.file()
72✔
257
  if (!data) {
72!
258
    return reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, 'Missing file')
×
259
  }
×
260
  const { file, mimetype, fields } = data
72✔
261
  const parsingOptions = Object.fromEntries(Object.values(fields)
72✔
262
    .filter(field => field.type === 'field')
72✔
263
    .map(({ fieldname, value }) => [fieldname, value]))
72✔
264

72✔
265
  const {
72✔
266
    log,
72✔
267
    crudContext,
72✔
268
    routeOptions: { config: { itemValidator, validateImportOptions } },
72✔
269
  } = request
72✔
270
  const isValid = validateImportOptions(parsingOptions)
72✔
271
  if (!isValid) {
72✔
272
    return reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, `Invalid options`)
12✔
273
  }
12✔
274

60✔
275
  const bodyParser = getFileMimeParser(mimetype, parsingOptions)
60✔
276
  if (!bodyParser) {
72!
277
    return reply.getHttpError(UNSUPPORTED_MIME_TYPE_STATUS_CODE, `Unsupported file type ${mimetype}`)
×
278
  }
×
279

60✔
280
  const { crudService, queryParser } = this
60✔
281

60✔
282
  let documentIndex = 0
60✔
283
  const parseDocument = through2.obj((chunk, _enc, callback) => {
60✔
284
    try {
144✔
285
      itemValidator(chunk)
144✔
286
      if (itemValidator.errors) { throw itemValidator.errors }
144✔
287
    } catch (error) {
144✔
288
      return callback(error, chunk)
6✔
289
    }
6✔
290
    documentIndex += 1
138✔
291
    return callback(null, chunk)
138✔
292
  })
60✔
293

60✔
294
  // POST
60✔
295
  let returnCode = 201
60✔
296
  let processBatch = async(batch) => crudService.insertMany(crudContext, batch, queryParser)
60✔
297

60✔
298
  // PATCH
60✔
299
  if (request.method === 'PATCH') {
66✔
300
    returnCode = 200
39✔
301
    processBatch = async(batch) => {
39✔
302
      return crudService.upsertMany(crudContext, batch, queryParser)
36✔
303
    }
36✔
304
  }
39✔
305

60✔
306
  const batchConsumer = new BatchWritableStream({
60✔
307
    batchSize: 5000,
60✔
308
    highWaterMark: 1000,
60✔
309
    objectMode: true,
60✔
310
    processBatch,
60✔
311
  })
60✔
312

60✔
313
  const ac = new AbortController()
60✔
314
  const { signal } = ac
60✔
315

60✔
316
  // ensure that the pipeline is destroyed
60✔
317
  // in case the response stream is destroyed
60✔
318
  file.on('error', () => ac.abort())
60✔
319

60✔
320
  try {
60✔
321
    await pipeline(
60✔
322
      file,
60✔
323
      bodyParser(),
60✔
324
      parseDocument,
60✔
325
      batchConsumer,
60✔
326
      { signal }
60✔
327
    )
60✔
328
  } catch (error) {
72✔
329
    if (error.code === OPTIONS_INCOMPATIBILITY_ERROR_CODE) {
9!
330
      log.debug('stream error')
×
331
      return reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, error.message)
×
332
    }
×
333

9✔
334
    if (error.code === UNIQUE_INDEX_MONGO_ERROR_CODE) {
9✔
335
      log.debug('unique index violation')
3✔
336
      return reply.getHttpError(UNIQUE_INDEX_ERROR_STATUS_CODE, error.message)
3✔
337
    }
3✔
338

6✔
339
    if (Array.isArray(error)) {
6✔
340
      log.debug('error parsing input file')
6✔
341
      const { message, instancePath } = error?.[0] ?? {}
6!
342
      const errorDetails = instancePath ? `, ${instancePath}` : ''
6!
343
      const errorMessage = `(index: ${documentIndex}${errorDetails}) ${message ?? 'error in parsing record'}`
6!
344
      return reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, errorMessage)
6✔
345
    }
6✔
346

×
NEW
347
    ac.abort(error)
×
NEW
348

×
349
    return reply.getHttpError(INTERNAL_SERVER_ERROR_STATUS_CODE, error.message || 'something went wrong')
9✔
350
  }
9✔
351

51✔
352
  return reply.code(returnCode).send({ message: 'File uploaded successfully' })
51✔
353
}
72✔
354

93✔
355
function getExportColumns(projection) {
1,221✔
356
  const columns = Object.keys(projection).filter(key => projection[key] !== 0)
1,221✔
357
  if (!columns.includes('_id') && projection['_id'] !== 0) {
1,221✔
358
    columns.unshift('_id')
375✔
359
  }
375✔
360
  return columns
1,221✔
361
}
1,221✔
362

93✔
363
// eslint-disable-next-line max-statements
93✔
364
async function handleGetListLookup(request, reply) {
60✔
365
  this.customMetrics?.collectionInvocation?.inc({
60✔
366
    collection_name: this.modelName,
60✔
367
    type: PROMETHEUS_OP_TYPE.FETCH,
60✔
368
  })
60✔
369

60✔
370
  const {
60✔
371
    query,
60✔
372
    headers,
60✔
373
    crudContext,
60✔
374
    log,
60✔
375
    routeOptions: { config: { replyType, streamValidator } },
60✔
376
  } = request
60✔
377

60✔
378
  const {
60✔
379
    [QUERY]: clientQueryString,
60✔
380
    [PROJECTION]: clientProjectionString = '',
60✔
381
    [SORT]: sortQuery,
60✔
382
    [LIMIT]: limit,
60✔
383
    [SKIP]: skip,
60✔
384
    [STATE]: state,
60✔
385
    [EXPORT_OPTIONS]: exportOpts = '',
60✔
386
    ...otherParams
60✔
387
  } = query
60✔
388
  const { acl_rows, acl_read_columns } = headers
60✔
389

60✔
390
  let projection = resolveProjection(
60✔
391
    clientProjectionString,
60✔
392
    acl_read_columns,
60✔
393
    this.allFieldNames,
60✔
394
    '',
60✔
395
    log
60✔
396
  )
60✔
397
  delete projection._id
60✔
398

60✔
399
  projection = this.lookupProjection.reduce((acc, proj) => {
60✔
400
    if (projection[Object.keys(proj)[0]]) {
180✔
401
      return { ...acc, ...proj }
102✔
402
    }
102✔
403
    return acc
78✔
404
  }, {})
60✔
405
  if (Object.keys(projection).length === 0) {
60✔
406
    reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, 'No allowed colums')
3✔
407
  }
3✔
408

60✔
409
  const lookupProjectionFieldsToOmit = this.lookupProjection.reduce((acc, field) => {
60✔
410
    if (Object.values(field).shift() === 0) {
180✔
411
      return { ...acc, ...field }
60✔
412
    }
60✔
413
    return acc
120✔
414
  },
60✔
415
  {})
60✔
416
  projection = {
60✔
417
    ...projection,
60✔
418
    ...lookupProjectionFieldsToOmit,
60✔
419
  }
60✔
420

60✔
421
  const isTextSearchQuery = query._q && this.queryParser.isTextSearchQuery(JSON.parse(query._q))
60✔
422
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, isTextSearchQuery)
60✔
423
  let sort
60✔
424
  if (sortQuery) {
60✔
425
    sort = Object.fromEntries(sortQuery.toString().split(',')
6✔
426
      .map((param) => (param[0] === '-' ? [param.substr(1), -1] : [param, 1])))
6✔
427
  }
6✔
428

60✔
429
  const stateArr = state?.split(',')
60✔
430
  const contentType = replyType()
60✔
431
  const parsingOptions = contentType === 'text/csv' && exportOpts ? JSON.parse(exportOpts) : {}
60!
432

60✔
433
  const responseStringifiers = getFileMimeStringifiers(contentType, parsingOptions)
60✔
434
  if (!responseStringifiers) {
60!
435
    return reply.getHttpError(UNSUPPORTED_MIME_TYPE_STATUS_CODE, `Unsupported file type ${contentType}`)
×
436
  }
×
437

60✔
438
  reply.raw.setHeader('Content-Type', contentType)
60✔
439

60✔
440
  // the AbortController is necessary to ensure
60✔
441
  // that resources are cleared upon encountering an error
60✔
442
  const ac = new AbortController()
60✔
443
  const { signal } = ac
60✔
444

60✔
445
  // in case a socket is not available (e.g. the client stopped the request abruptly)
60✔
446
  // do not open a cursor on the database but rather return an error
60✔
447
  if (!reply.raw.socket) {
60!
NEW
448
    request.log.warn('socket not available - request aborted')
×
NEW
449
    ac.abort(new Error('socket not available'))
×
UNCOV
450

×
NEW
451
    reply.code(INTERNAL_SERVER_ERROR_STATUS_CODE).send({ msg: 'socket not available' })
×
NEW
452
    return reply
×
NEW
453
  }
×
454

60✔
455
  let cursor
60✔
456
  try {
60✔
457
    cursor = this.crudService
60✔
458
      .aggregate(crudContext, mongoQuery, projection, sort, skip, limit, stateArr, isTextSearchQuery, { signal })
60✔
459

60✔
460
    const dataStream = cursor.stream({ transform: castItem })
60✔
461
    const serializers = responseStringifiers({ fields: getExportColumns(projection) })
60✔
462

60✔
463
    if (streamValidator) {
60!
NEW
464
      await pipeline(dataStream, streamValidator(), ...serializers, reply.raw, { signal })
×
465
    } else {
60✔
466
      await pipeline(dataStream, ...serializers, reply.raw, { signal })
60✔
467
    }
57✔
468
  } catch (error) {
60✔
469
    request.log.error({ error }, 'Error during findAll lookup stream')
3✔
470
    request.log.debug({ error: { ...error, message: error.message } }, 'Error during findAll lookup stream with message')
3✔
471
    if (error.code === OPTIONS_INCOMPATIBILITY_ERROR_CODE) {
3!
472
      request.log.info(BAD_REQUEST_ERROR_STATUS_CODE)
×
473
    }
×
474

3✔
475
    // ensure the abort signal is propagated
3✔
476
    ac.abort(error)
3✔
477

3✔
478
    // read buffered documents to remove them
3✔
479
    cursor.readBufferedDocuments()
3✔
480
  } finally {
60✔
481
    // ensure cursor is closed
60✔
482
    await cursor.close()
60✔
483
    request.log.debug({ 'isClosed': cursor.closed }, 'findAll cursor closed')
60✔
484
  }
60✔
485
  return reply
60✔
486
}
60✔
487

93✔
488
// eslint-disable-next-line max-statements
93✔
489
async function handleGetList(request, reply) {
1,389✔
490
  this.customMetrics?.collectionInvocation?.inc({
1,389✔
491
    collection_name: this.modelName,
1,389✔
492
    type: PROMETHEUS_OP_TYPE.FETCH,
1,389✔
493
  })
1,389✔
494

1,389✔
495
  const {
1,389✔
496
    query,
1,389✔
497
    headers,
1,389✔
498
    crudContext,
1,389✔
499
    log,
1,389✔
500
    routeOptions: { config: { replyType, streamValidator } },
1,389✔
501
  } = request
1,389✔
502
  const {
1,389✔
503
    [QUERY]: clientQueryString,
1,389✔
504
    [PROJECTION]: clientProjectionString = '',
1,389✔
505
    [RAW_PROJECTION]: clientRawProjectionString = '',
1,389✔
506
    [SORT]: sortQuery,
1,389✔
507
    [LIMIT]: limit,
1,389✔
508
    [SKIP]: skip,
1,389✔
509
    [STATE]: state,
1,389✔
510
    [EXPORT_OPTIONS]: exportOpts = '',
1,389✔
511
    ...otherParams
1,389✔
512
  } = query
1,389✔
513
  const { acl_rows, acl_read_columns, accept } = headers
1,389✔
514
  const contentType = replyType(accept)
1,389✔
515
  const parsingOptions = contentType === 'text/csv' && exportOpts ? JSON.parse(exportOpts) : {}
1,389✔
516

1,389✔
517
  const responseStringifiers = getFileMimeStringifiers(contentType, parsingOptions)
1,389✔
518
  if (!responseStringifiers) {
1,389✔
519
    return reply.getHttpError(NOT_ACCEPTABLE, `unsupported file type ${contentType}`)
162✔
520
  }
162✔
521

1,227✔
522
  const projection = resolveProjection(
1,227✔
523
    clientProjectionString,
1,227✔
524
    acl_read_columns,
1,227✔
525
    this.allFieldNames,
1,227✔
526
    clientRawProjectionString,
1,227✔
527
    log
1,227✔
528
  )
1,227✔
529

1,227✔
530
  const isTextSearchQuery = query._q && this.queryParser.isTextSearchQuery(JSON.parse(query._q))
1,389✔
531
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, isTextSearchQuery)
1,389✔
532

1,389✔
533
  let sort
1,389✔
534
  if (sortQuery) {
1,389✔
535
    sort = Object.fromEntries(sortQuery.toString().split(',')
147✔
536
      .map((param) => (param[0] === '-' ? [param.substr(1), -1] : [param, 1])))
147✔
537
  }
147✔
538

1,161✔
539
  const stateArr = state.split(',')
1,161✔
540

1,161✔
541
  reply.raw.setHeader('Content-Type', contentType)
1,161✔
542

1,161✔
543
  // the AbortController is necessary to ensure
1,161✔
544
  // that resources are cleared upon encountering an error
1,161✔
545
  const ac = new AbortController()
1,161✔
546
  const { signal } = ac
1,161✔
547

1,161✔
548
  // in case a socket is not available (e.g. the client stopped the request abruptly)
1,161✔
549
  // do not open a cursor on the database but rather return an error
1,161✔
550
  if (!reply.raw.socket) {
1,389!
NEW
551
    request.log.warn('socket not available - request aborted')
×
NEW
552
    ac.abort(new Error('socket not available'))
×
UNCOV
553

×
NEW
554
    reply.code(INTERNAL_SERVER_ERROR_STATUS_CODE).send({ msg: 'socket not available' })
×
NEW
555
    return reply
×
NEW
556
  }
×
557

1,161✔
558
  let cursor
1,161✔
559
  try {
1,161✔
560
    cursor = this.crudService
1,161✔
561
      .findAll(
1,161✔
562
        crudContext, mongoQuery, projection, sort, skip, limit, stateArr, isTextSearchQuery,
1,161✔
563
        { signal }
1,161✔
564
      )
1,161✔
565

1,161✔
566
    const dataStream = cursor.stream({ transform: castItem })
1,161✔
567
    const serializers = responseStringifiers({ fields: getExportColumns(projection) })
1,161✔
568

1,161✔
569
    if (streamValidator) {
1,389✔
570
      await pipeline(dataStream, streamValidator(), ...serializers, reply.raw, { signal })
18✔
571
    } else {
1,389✔
572
      await pipeline(dataStream, ...serializers, reply.raw, { signal })
1,143✔
573
    }
1,143✔
574
  } catch (error) {
1,389✔
575
    request.log.error({ error }, 'Error during findAll stream')
3✔
576
    request.log.debug({ error: { ...error, message: error.message } }, 'Error during findAll stream with message')
3✔
577
    if (error.code === OPTIONS_INCOMPATIBILITY_ERROR_CODE) {
3!
578
      request.log.info(BAD_REQUEST_ERROR_STATUS_CODE)
×
579
    }
×
580
    // ensure the abort signal is propagated
3✔
581
    ac.abort(error)
3✔
582

3✔
583
    // read buffered documents to remove them
3✔
584
    cursor.readBufferedDocuments()
3✔
585
  } finally {
1,389✔
586
    // ensure cursor is closed
1,161✔
587
    await cursor.close()
1,161✔
588
    request.log.debug({ 'isClosed': cursor.closed }, 'findAll cursor closed')
1,161✔
589
  }
1,161✔
590
}
1,389✔
591

93✔
592
async function handleGetId(request, reply) {
375✔
593
  this.customMetrics?.collectionInvocation?.inc({
375!
594
    collection_name: this.modelName,
375✔
595
    type: PROMETHEUS_OP_TYPE.FETCH,
375✔
596
  })
375✔
597

375✔
598
  const {
375✔
599
    crudContext,
375✔
600
    log,
375✔
601
  } = request
375✔
602
  const docId = request.params.id
375✔
603
  const { acl_rows, acl_read_columns } = request.headers
375✔
604

375✔
605
  const {
375✔
606
    [QUERY]: clientQueryString,
375✔
607
    [PROJECTION]: clientProjectionString = '',
375✔
608
    [RAW_PROJECTION]: clientRawProjectionString = '',
375✔
609
    [STATE]: state,
375✔
610
    ...otherParams
375✔
611
  } = request.query
375✔
612

375✔
613
  const projection = resolveProjection(
375✔
614
    clientProjectionString,
375✔
615
    acl_read_columns,
375✔
616
    this.allFieldNames,
375✔
617
    clientRawProjectionString,
375✔
618
    log
375✔
619
  )
375✔
620
  const filter = resolveMongoQuery(
375✔
621
    this.queryParser,
375✔
622
    clientQueryString,
375✔
623
    acl_rows,
375✔
624
    otherParams,
375✔
625
    false
375✔
626
  )
375✔
627
  const _id = castCollectionId(docId)
375✔
628

375✔
629
  const stateArr = state.split(',')
375✔
630
  const doc = await this.crudService.findById(crudContext, _id, filter, projection, stateArr)
375✔
631
  if (!doc) {
375✔
632
    return reply.notFound()
78✔
633
  }
78✔
634

276✔
635
  return doc
276✔
636
}
375✔
637

93✔
638
async function handleInsertOne(request, reply) {
78✔
639
  this.customMetrics?.collectionInvocation?.inc({
78✔
640
    collection_name: this.modelName,
78✔
641
    type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
78✔
642
  })
78✔
643

78✔
644
  const { body: doc, crudContext } = request
78✔
645

78✔
646
  this.queryParser.parseAndCastBody(doc)
78✔
647

78✔
648
  try {
78✔
649
    const insertedDoc = await this.crudService.insertOne(crudContext, doc)
78✔
650
    return mapToObjectWithOnlyId(insertedDoc)
75✔
651
  } catch (error) {
78✔
652
    if (error.code === UNIQUE_INDEX_MONGO_ERROR_CODE) {
3✔
653
      request.log.error('unique index violation')
3✔
654
      return reply.getHttpError(UNIQUE_INDEX_ERROR_STATUS_CODE, error.message)
3✔
655
    }
3✔
656
    throw error
×
657
  }
×
658
}
78✔
659

93✔
660
async function handleValidate() {
3✔
661
  return { result: 'ok' }
3✔
662
}
3✔
663

93✔
664
async function handleDeleteId(request, reply) {
57✔
665
  this.customMetrics?.collectionInvocation?.inc({
57✔
666
    collection_name: this.modelName,
57✔
667
    type: PROMETHEUS_OP_TYPE.DELETE,
57✔
668
  })
57✔
669

57✔
670
  const { query, headers, params, crudContext } = request
57✔
671

57✔
672
  const docId = params.id
57✔
673
  const _id = castCollectionId(docId)
57✔
674

57✔
675
  const {
57✔
676
    [QUERY]: clientQueryString,
57✔
677
    [STATE]: state,
57✔
678
    ...otherParams
57✔
679
  } = query
57✔
680
  const { acl_rows } = headers
57✔
681

57✔
682
  const filter = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
57✔
683

57✔
684
  const stateArr = state.split(',')
57✔
685
  const doc = await this.crudService.deleteById(crudContext, _id, filter, stateArr)
57✔
686

54✔
687
  if (!doc) {
57✔
688
    return reply.notFound()
18✔
689
  }
18✔
690

36✔
691
  // the document should not be returned:
36✔
692
  // we don't know which projection the user is able to see
36✔
693
  reply.code(204)
36✔
694
}
57✔
695

93✔
696
async function handleDeleteList(request) {
42✔
697
  this.customMetrics?.collectionInvocation?.inc({
42✔
698
    collection_name: this.modelName,
42✔
699
    type: PROMETHEUS_OP_TYPE.DELETE,
42✔
700
  })
42✔
701

42✔
702
  const { query, headers, crudContext } = request
42✔
703

42✔
704
  const {
42✔
705
    [QUERY]: clientQueryString,
42✔
706
    [STATE]: state,
42✔
707
    ...otherParams
42✔
708
  } = query
42✔
709
  const { acl_rows } = headers
42✔
710

42✔
711
  const filter = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
42✔
712

42✔
713
  const stateArr = state.split(',')
42✔
714
  return this.crudService.deleteAll(crudContext, filter, stateArr)
42✔
715
}
42✔
716

93✔
717
async function handleCount(request) {
51✔
718
  this.customMetrics?.collectionInvocation?.inc({
51✔
719
    collection_name: this.modelName,
51✔
720
    type: PROMETHEUS_OP_TYPE.FETCH,
51✔
721
  })
51✔
722

51✔
723
  const { query, headers, crudContext } = request
51✔
724
  const {
51✔
725
    [QUERY]: clientQueryString,
51✔
726
    [STATE]: state,
51✔
727
    [USE_ESTIMATE]: useEstimate,
51✔
728
    ...otherParams
51✔
729
  } = query
51✔
730

51✔
731
  const { acl_rows } = headers
51✔
732

51✔
733
  if (useEstimate) {
51✔
734
    return this.crudService.estimatedDocumentCount(crudContext)
6✔
735
  }
6✔
736

45✔
737
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
45✔
738
  const stateArr = state.split(',')
45✔
739

45✔
740
  return this.crudService.count(crudContext, mongoQuery, stateArr)
45✔
741
}
51✔
742

93✔
743
async function handlePatchId(request, reply) {
228✔
744
  this.customMetrics?.collectionInvocation?.inc({
228✔
745
    collection_name: this.modelName,
228✔
746
    type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
228✔
747
  })
228✔
748

228✔
749
  const {
228✔
750
    query,
228✔
751
    headers,
228✔
752
    params,
228✔
753
    crudContext,
228✔
754
    log,
228✔
755
  } = request
228✔
756

228✔
757
  const {
228✔
758
    [QUERY]: clientQueryString,
228✔
759
    [STATE]: state,
228✔
760
    ...otherParams
228✔
761
  } = query
228✔
762
  const {
228✔
763
    acl_rows,
228✔
764
    acl_write_columns: aclWriteColumns,
228✔
765
    acl_read_columns: aclColumns = '',
228✔
766
  } = headers
228✔
767

228✔
768
  const commands = request.body
228✔
769

228✔
770
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
228✔
771

228✔
772
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
228✔
773

228✔
774
  this.queryParser.parseAndCastCommands(commands, editableFields)
228✔
775
  const projection = resolveProjection('', aclColumns, this.allFieldNames, '', log)
228✔
776

228✔
777
  const docId = params.id
228✔
778
  const _id = castCollectionId(docId)
228✔
779

228✔
780
  const stateArr = state.split(',')
228✔
781
  const doc = await this.crudService.patchById(crudContext, _id, commands, mongoQuery, projection, stateArr)
228✔
782

222✔
783
  if (!doc) {
228✔
784
    return reply.notFound()
51✔
785
  }
51✔
786

171✔
787
  return doc
171✔
788
}
228✔
789

93✔
790
async function handlePatchMany(request) {
90✔
791
  this.customMetrics?.collectionInvocation?.inc({
90!
792
    collection_name: this.modelName,
90✔
793
    type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
90✔
794
  })
90✔
795

90✔
796
  const { query, headers, crudContext } = request
90✔
797
  const {
90✔
798
    [QUERY]: clientQueryString,
90✔
799
    [STATE]: state,
90✔
800
    ...otherParams
90✔
801
  } = query
90✔
802
  const {
90✔
803
    acl_rows,
90✔
804
    acl_write_columns: aclWriteColumns,
90✔
805
  } = headers
90✔
806

90✔
807
  const commands = request.body
90✔
808
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
90✔
809
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
90✔
810
  this.queryParser.parseAndCastCommands(commands, editableFields)
90✔
811

90✔
812
  const stateArr = state.split(',')
90✔
813
  const nModified = await this.crudService.patchMany(crudContext, commands, mongoQuery, stateArr)
90✔
814

84✔
815
  return nModified
84✔
816
}
90✔
817

93✔
818
async function handleUpsertOne(request) {
66✔
819
  this.customMetrics?.collectionInvocation?.inc({
66!
820
    collection_name: this.modelName,
66✔
821
    type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
66✔
822
  })
66✔
823

66✔
824
  const {
66✔
825
    query,
66✔
826
    headers,
66✔
827
    crudContext,
66✔
828
    log,
66✔
829
    routeOptions: { config: { itemValidator } },
66✔
830
  } = request
66✔
831
  const {
66✔
832
    [QUERY]: clientQueryString,
66✔
833
    [STATE]: state,
66✔
834
    ...otherParams
66✔
835
  } = query
66✔
836
  const {
66✔
837
    acl_rows,
66✔
838
    acl_write_columns: aclWriteColumns,
66✔
839
    acl_read_columns: aclColumns = '',
66✔
840
  } = headers
66✔
841

66✔
842
  const commands = request.body
66✔
843

66✔
844
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
66✔
845
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
66✔
846

66✔
847
  this.queryParser.parseAndCastCommands(commands, editableFields)
66✔
848
  const projection = resolveProjection('', aclColumns, this.allFieldNames, '', log)
66✔
849

66✔
850
  const stateArr = state.split(',')
66✔
851
  const doc = await this.crudService.upsertOne(crudContext, commands, mongoQuery, projection, stateArr)
66✔
852

66✔
853
  // validate the document only in case the validator is found
66✔
854
  itemValidator?.(doc)
66!
855
  return doc
66✔
856
}
66✔
857

93✔
858
async function handlePatchBulk(request) {
90✔
859
  this.customMetrics?.collectionInvocation?.inc({
90!
860
    collection_name: this.modelName,
90✔
861
    type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
90✔
862
  })
90✔
863

90✔
864
  const { body: filterUpdateCommands, crudContext, headers } = request
90✔
865

90✔
866
  return this.crudService.patchBulk(
90✔
867
    crudContext,
90✔
868
    filterUpdateCommands,
90✔
869
    this.queryParser,
90✔
870
    getEditableFields(headers[ACL_WRITE_COLUMNS], this.allFieldNames),
90✔
871
    headers[ACL_ROWS],
90✔
872
  )
90✔
873
}
90✔
874

93✔
875
async function handleInsertMany(request, reply) {
63✔
876
  this.customMetrics?.collectionInvocation?.inc({
63✔
877
    collection_name: this.modelName,
63✔
878
    type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
63✔
879
  })
63✔
880

63✔
881
  const { body: docs, crudContext } = request
63✔
882

63✔
883
  try {
63✔
884
    return await this.crudService.insertMany(
63✔
885
      crudContext,
63✔
886
      docs,
63✔
887
      this.queryParser,
63✔
888
      { idOnly: true }
63✔
889
    )
63✔
890
  } catch (error) {
63✔
891
    if (error.code === UNIQUE_INDEX_MONGO_ERROR_CODE) {
3✔
892
      request.log.error('unique index violation')
3✔
893
      return reply.getHttpError(UNIQUE_INDEX_ERROR_STATUS_CODE, error.message)
3✔
894
    }
3✔
895
    throw error
×
896
  }
×
897
}
63✔
898

93✔
899
async function handleChangeStateById(request, reply) {
51✔
900
  this.customMetrics?.collectionInvocation?.inc({
51!
901
    collection_name: this.modelName,
51✔
902
    type: PROMETHEUS_OP_TYPE.CHANGE_STATE,
51✔
903
  })
51✔
904

51✔
905
  const { body, crudContext, headers, query } = request
51✔
906
  const {
51✔
907
    [QUERY]: clientQueryString,
51✔
908
    ...otherParams
51✔
909
  } = query
51✔
910

51✔
911
  const { acl_rows } = headers
51✔
912
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
51✔
913

51✔
914
  const docId = request.params.id
51✔
915
  const _id = castCollectionId(docId)
51✔
916

51✔
917
  try {
51✔
918
    const doc = await this.crudService.changeStateById(crudContext, _id, body.stateTo, mongoQuery)
51✔
919
    if (!doc) {
51✔
920
      return reply.notFound()
9✔
921
    }
9✔
922

24✔
923
    reply.code(204)
24✔
924
  } catch (error) {
51✔
925
    if (error.statusCode) {
15✔
926
      return reply.getHttpError(error.statusCode, error.message)
15✔
927
    }
15✔
928

×
929
    throw error
×
930
  }
×
931
}
51✔
932

93✔
933
async function handleChangeStateMany(request) {
48✔
934
  this.customMetrics?.collectionInvocation?.inc({
48!
935
    collection_name: this.modelName,
48✔
936
    type: PROMETHEUS_OP_TYPE.CHANGE_STATE,
48✔
937
  })
48✔
938

48✔
939
  const { body: filterUpdateCommands, crudContext, headers } = request
48✔
940

48✔
941
  const {
48✔
942
    acl_rows,
48✔
943
  } = headers
48✔
944

48✔
945
  const parsedAndCastedCommands = new Array(filterUpdateCommands.length)
48✔
946
  for (let i = 0; i < filterUpdateCommands.length; i++) {
48✔
947
    const {
60✔
948
      filter,
60✔
949
      stateTo,
60✔
950
    } = filterUpdateCommands[i]
60✔
951

60✔
952
    const mongoQuery = resolveMongoQuery(this.queryParser, null, acl_rows, filter, false)
60✔
953

60✔
954
    parsedAndCastedCommands[i] = {
60✔
955
      query: mongoQuery,
60✔
956
      stateTo,
60✔
957
    }
60✔
958
  }
60✔
959

48✔
960
  return this.crudService.changeStateMany(crudContext, parsedAndCastedCommands)
48✔
961
}
48✔
962

93✔
963
function resolveProjection(clientProjectionString, aclColumns, allFieldNames, rawProjection, log) {
1,953✔
964
  log.debug('Resolving projections')
1,953✔
965
  const acls = splitACLs(aclColumns)
1,953✔
966

1,953✔
967
  if (clientProjectionString && rawProjection) {
1,953✔
968
    log.error('Use of both _p and _rawp is not permitted')
18✔
969
    throw new BadRequestError(
18✔
970
      'Use of both _rawp and _p parameter is not allowed')
18✔
971
  }
18✔
972

1,935✔
973
  let projection
1,935✔
974
  if (!clientProjectionString && !rawProjection) {
1,953✔
975
    projection = removeAclColumns(allFieldNames, acls)
1,275✔
976
  } else if (rawProjection) {
1,509✔
977
    projection = resolveRawProjectionString(rawProjection, acls, allFieldNames, log)
276✔
978
  } else if (clientProjectionString) {
660✔
979
    projection = resolveClientProjectionString(clientProjectionString, acls)
384✔
980
  }
384✔
981

1,869✔
982
  return getProjection(projection)
1,869✔
983
}
1,953✔
984

93✔
985
function getProjection(projection) {
1,869✔
986
  // In case of empty projection, we project only the _id
1,869✔
987
  if (!projection?.length) { return { _id: 1 } }
1,869✔
988

1,821✔
989
  return projection.reduce((acc, val) => {
1,821✔
990
    const propertiesToInclude = typeof val === 'string'
25,029✔
991
      // a string represents the name of a field to be projected
25,029✔
992
      ? { [val]: 1 }
25,029✔
993
      // an object represents a raw projection to be passed as it is
25,029✔
994
      : val
25,029✔
995

25,029✔
996
    return { ...acc, ...propertiesToInclude }
25,029✔
997
  }, {})
1,821✔
998
}
1,869✔
999

93✔
1000
function resolveClientProjectionString(clientProjectionString, _acls) {
384✔
1001
  const clientProjection = getClientProjection(clientProjectionString)
384✔
1002
  return removeAclColumns(clientProjection, _acls)
384✔
1003
}
384✔
1004

93✔
1005
function resolveRawProjectionString(rawProjection, _acls, allFieldNames, log) {
276✔
1006
  try {
276✔
1007
    checkAllowedOperators(
276✔
1008
      rawProjection,
276✔
1009
      rawProjectionDictionary,
276✔
1010
      _acls.length > 0 ? _acls : allFieldNames, log)
276✔
1011

276✔
1012
    const rawProjectionObject = resolveRawProjection(rawProjection)
276✔
1013
    const projection = removeAclColumnsFromRawProjection(rawProjectionObject, _acls)
276✔
1014

276✔
1015
    return !lisEmpty(projection) ? [projection] : []
276✔
1016
  } catch (errorMessage) {
276✔
1017
    log.error(errorMessage.message)
66✔
1018
    throw new BadRequestError(errorMessage.message)
66✔
1019
  }
66✔
1020
}
276✔
1021

93✔
1022
function splitACLs(acls) {
1,953✔
1023
  if (acls) { return acls.split(',') }
1,953✔
1024
  return []
1,752✔
1025
}
1,953✔
1026

93✔
1027
function removeAclColumns(fieldsInProjection, aclColumns) {
1,869✔
1028
  if (aclColumns.length > 0) {
1,869✔
1029
    return fieldsInProjection.filter(field => {
195✔
1030
      return aclColumns.indexOf(field) > -1
1,104✔
1031
    })
195✔
1032
  }
195✔
1033

1,674✔
1034
  return fieldsInProjection
1,674✔
1035
}
1,869✔
1036

93✔
1037
function removeAclColumnsFromRawProjection(rawProjectionObject, aclColumns) {
216✔
1038
  const isRawProjectionOverridingACLs = checkIfRawProjectionOverridesAcls(rawProjectionObject, aclColumns)
216✔
1039
  if (isRawProjectionOverridingACLs) {
216✔
1040
    throw Error('_rawp exclusive projection is overriding at least one acl_read_column value')
6✔
1041
  }
6✔
1042

210✔
1043
  const rawProjectionFields = Object.keys(rawProjectionObject)
210✔
1044
  const filteredFields = removeAclColumns(rawProjectionFields, aclColumns)
210✔
1045

210✔
1046
  return filteredFields.reduce((acc, current) => {
210✔
1047
    if (rawProjectionObject[current] === 0 || rawProjectionObject[current]) {
324✔
1048
      acc[current] = rawProjectionObject[current]
324✔
1049
    }
324✔
1050
    return acc
324✔
1051
  }, {})
210✔
1052
}
216✔
1053

93✔
1054
function getClientProjection(clientProjectionString) {
384✔
1055
  if (clientProjectionString) {
384✔
1056
    return clientProjectionString.split(',')
384✔
1057
  }
384✔
1058
  return []
×
1059
}
384✔
1060

93✔
1061
function resolveRawProjection(clientRawProjectionString) {
216✔
1062
  if (clientRawProjectionString) {
216✔
1063
    return JSON.parse(clientRawProjectionString)
216✔
1064
  }
216✔
1065
  return {}
×
1066
}
216✔
1067

93✔
1068
function checkAllowedOperators(rawProjection, projectionDictionary, additionalFields, log) {
276✔
1069
  if (!rawProjection) {
276!
1070
    log.debug('No raw projection found: checkAllowedOperators returns true')
×
1071
    return true
×
1072
  }
×
1073

276✔
1074
  const { allowedOperators, notAllowedOperators } = projectionDictionary
276✔
1075
  const allowedFields = [...allowedOperators]
276✔
1076

276✔
1077
  additionalFields.forEach(field => allowedFields.push(`$${field}`))
276✔
1078

276✔
1079
  log.debug({ allowedOperators: allowedFields }, 'Allowed operators for projection')
276✔
1080
  log.debug({ notAllowedOperators }, 'Not allowed operators for projection')
276✔
1081

276✔
1082
  // to match both camelCase operators and snake mongo_systems variables
276✔
1083
  const operatorsRegex = /\${1,2}[a-zA-Z_]+/g
276✔
1084
  const matches = rawProjection.match(operatorsRegex)
276✔
1085

276✔
1086
  if (!matches) {
276✔
1087
    log.debug('No operators found in raw projection: checkAllowedOperators returns true')
108✔
1088
    return true
108✔
1089
  }
108✔
1090

168✔
1091
  return !matches.some(match => {
168✔
1092
    if (match.startsWith('$$')) {
444✔
1093
      log.debug({ match }, 'Found $$ match in raw projection')
120✔
1094
      if (notAllowedOperators.includes(match)) {
120✔
1095
        throw Error(`Operator ${match} is not allowed in raw projection`)
48✔
1096
      }
48✔
1097

72✔
1098
      return notAllowedOperators.includes(match)
72✔
1099
    }
72✔
1100

324✔
1101
    if (!allowedFields.includes(match)) {
444✔
1102
      throw Error(`Operator ${match} is not allowed in raw projection`)
12✔
1103
    }
12✔
1104

312✔
1105
    return !allowedFields.includes(match)
312✔
1106
  })
168✔
1107
}
276✔
1108

93✔
1109
function checkIfRawProjectionOverridesAcls(rawProjection, acls) {
216✔
1110
  return Object.keys(rawProjection).some(field =>
216✔
1111
    acls.includes(field) && rawProjection[field] === 0
441✔
1112
  )
216✔
1113
}
216✔
1114

93✔
1115
function mapToObjectWithOnlyId(doc) {
75✔
1116
  return { _id: doc._id.toString() }
75✔
1117
}
75✔
1118

93✔
1119
const internalFields = [
93✔
1120
  UPDATERID,
93✔
1121
  UPDATEDAT,
93✔
1122
  CREATORID,
93✔
1123
  CREATEDAT,
93✔
1124
  __STATE__,
93✔
1125
]
93✔
1126
function getEditableFields(aclWriteColumns, allFieldNames) {
474✔
1127
  const editableFields = aclWriteColumns ? aclWriteColumns.split(',') : allFieldNames
474!
1128
  return editableFields.filter(ef => !internalFields.includes(ef))
474✔
1129
}
474✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc