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

mia-platform / crud-service / 4992176894

pending completion
4992176894

push

github

Daniele Bissoli
build(deps-dev): bump eslint from 8.36.0 to 8.40.0

966 of 1103 branches covered (87.58%)

Branch coverage included in aggregate %.

2069 of 2156 relevant lines covered (95.96%)

3971.35 hits per line

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

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

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

19
'use strict'
20

21
const Ajv = require('ajv')
24✔
22
const ajvFormats = require('ajv-formats')
24✔
23

24
const lget = require('lodash.get')
24✔
25
const JSONStream = require('JSONStream')
24✔
26
const through2 = require('through2')
24✔
27
const fastJson = require('fast-json-stringify')
24✔
28

29
const { isEmpty } = require('ramda')
24✔
30

31
const { SCHEMAS_ID } = require('./JSONSchemaGenerator')
24✔
32

33
const {
34
  SORT,
35
  PROJECTION,
36
  RAW_PROJECTION,
37
  QUERY,
38
  LIMIT,
39
  SKIP,
40
  STATE,
41
  INVALID_USERID,
42
  UPDATERID,
43
  UPDATEDAT,
44
  CREATORID,
45
  CREATEDAT,
46
  __STATE__,
47
  SCHEMA_CUSTOM_KEYWORDS,
48
  rawProjectionDictionary,
49
} = require('./consts')
24✔
50

51
const BadRequestError = require('./BadRequestError')
24✔
52

53
const BAD_REQUEST_ERROR_STATUS_CODE = 400
24✔
54
const INTERNAL_SERVER_ERROR_STATUS_CODE = 500
24✔
55
const OPTIONS_INCOMPATIBILITY_ERROR_CODE = 2
24✔
56
const UNIQUE_INDEX_ERROR_STATUS_CODE = 422
24✔
57
const UNIQUE_INDEX_MONGO_ERROR_CODE = 11000
24✔
58

59
const PROMETHEUS_OP_TYPE = {
24✔
60
  FETCH: 'fetch',
61
  INSERT_OR_UPDATE: 'insert_or_update',
62
  DELETE: 'delete',
63
  CHANGE_STATE: 'change_state',
64
}
65

66

67
// eslint-disable-next-line max-statements
68
module.exports = async function getHttpInterface(fastify, { isView }) {
24✔
69
  if (!fastify.crudService) { throw new Error('`fastify.crudService` is undefined') }
788!
70
  if (!fastify.queryParser) { throw new Error('`fastify.queryParser` is undefined') }
788!
71
  if (!fastify.castCollectionId) { throw new Error('`fastify.castCollectionId` is undefined') }
788!
72
  if (!fastify.castResultsAsStream) { throw new Error('`fastify.castResultsAsStream` is undefined') }
788!
73
  if (!fastify.castItem) { throw new Error('`fastify.castItem` is undefined') }
788!
74
  if (!fastify.allFieldNames) { throw new Error('`fastify.allFieldNames` is undefined') }
788!
75
  if (!fastify.jsonSchemaGenerator) { throw new Error('`fastify.jsonSchemaGenerator` is undefined') }
788!
76
  if (!fastify.jsonSchemaGeneratorWithNested) { throw new Error('`fastify.jsonSchemaGeneratorWithNested` is undefined') }
788!
77
  if (!fastify.userIdHeaderKey) { throw new Error('`fastify.userIdHeaderKey` is undefined') }
788!
78
  if (!fastify.modelName) { throw new Error('`fastify.modelName` is undefined') }
788!
79

80
  const NESTED_SCHEMAS_BY_ID = {
788✔
81
    [SCHEMAS_ID.GET_LIST]: fastify.jsonSchemaGeneratorWithNested.generateGetListJSONSchema(),
82
    [SCHEMAS_ID.GET_ITEM]: fastify.jsonSchemaGeneratorWithNested.generateGetItemJSONSchema(),
83
    [SCHEMAS_ID.EXPORT]: fastify.jsonSchemaGeneratorWithNested.generateExportJSONSchema(),
84
    [SCHEMAS_ID.POST_ITEM]: fastify.jsonSchemaGeneratorWithNested.generatePostJSONSchema(),
85
    [SCHEMAS_ID.POST_BULK]: fastify.jsonSchemaGeneratorWithNested.generateBulkJSONSchema(),
86
    [SCHEMAS_ID.DELETE_ITEM]: fastify.jsonSchemaGeneratorWithNested.generateDeleteJSONSchema(),
87
    [SCHEMAS_ID.DELETE_LIST]: fastify.jsonSchemaGeneratorWithNested.generateDeleteListJSONSchema(),
88
    [SCHEMAS_ID.PATCH_ITEM]: fastify.jsonSchemaGeneratorWithNested.generatePatchJSONSchema(),
89
    [SCHEMAS_ID.PATCH_MANY]: fastify.jsonSchemaGeneratorWithNested.generatePatchManyJSONSchema(),
90
    [SCHEMAS_ID.PATCH_BULK]: fastify.jsonSchemaGeneratorWithNested.generatePatchBulkJSONSchema(),
91
    [SCHEMAS_ID.UPSERT_ONE]: fastify.jsonSchemaGeneratorWithNested.generateUpsertOneJSONSchema(),
92
    [SCHEMAS_ID.COUNT]: fastify.jsonSchemaGeneratorWithNested.generateCountJSONSchema(),
93
    [SCHEMAS_ID.VALIDATE]: fastify.jsonSchemaGeneratorWithNested.generateValidateJSONSchema(),
94
    [SCHEMAS_ID.CHANGE_STATE]: fastify.jsonSchemaGeneratorWithNested.generateChangeStateJSONSchema(),
95
    [SCHEMAS_ID.CHANGE_STATE_MANY]: fastify.jsonSchemaGeneratorWithNested.generateChangeStateManyJSONSchema(),
96
  }
97

98
  // for each collection define its dedicated validator instance
99
  const ajv = new Ajv({
788✔
100
    coerceTypes: true,
101
    useDefaults: true,
102
    // allow properties and pattern properties to overlap -> this should help validating nested fields
103
    allowMatchingProperties: true,
104
  })
105
  ajvFormats(ajv)
788✔
106
  ajv.addVocabulary(Object.values(SCHEMA_CUSTOM_KEYWORDS))
788✔
107

108
  fastify.setValidatorCompiler(({ schema }) => {
788✔
109
    const uniqueId = schema[SCHEMA_CUSTOM_KEYWORDS.UNIQUE_OPERATION_ID]
22,616✔
110
    if (!uniqueId) {
22,616!
111
      fastify.log.error('uniqueId not found')
×
112
      throw new Error('uniqueId of schema is not set')
×
113
    }
114
    const [collectionName, schemaId, subSchemaPath] = uniqueId.split('__MIA__')
22,616✔
115
    if (!(schemaId && subSchemaPath)) {
22,616!
116
      fastify.log.error({ collectionName, uniqueId }, 'uniqueId invalid')
×
117
      throw new Error('uniqueId of schema is invalid')
×
118
    }
119
    const nestedSchema = NESTED_SCHEMAS_BY_ID[schemaId]
22,616✔
120
    if (!nestedSchema) {
22,616!
121
      fastify.log.error({ collectionName, schemaId }, 'nested schema not found')
×
122
      throw new Error('nested schema not found')
×
123
    }
124
    const subSchema = lget(nestedSchema, subSchemaPath)
22,616✔
125
    if (!subSchema) {
22,616!
126
      fastify.log.error({ collectionName, schemaPath: subSchemaPath, schemaId }, 'sub schema not found')
×
127
      throw new Error('sub schema not found')
×
128
    }
129
    // this is made to prevent to shows on swagger all properties with dot notation of RawObject with schema.
130
    return ajv.compile(subSchema)
22,616✔
131
  })
132

133
  const getItemJSONSchema = fastify.jsonSchemaGenerator.generateGetItemJSONSchema()
788✔
134

135
  fastify.addHook('preHandler', injectContextInRequest)
788✔
136
  fastify.addHook('preHandler', request => parseEncodedJsonQueryParams(fastify.log, request))
788✔
137
  fastify.get('/', {
788✔
138
    schema: fastify.jsonSchemaGenerator.generateGetListJSONSchema(),
139
    config: {
140
      replyType: 'application/json',
141
      serializer: streamSerializer,
142
    },
143
  }, handleGetList)
144
  fastify.get('/export', {
788✔
145
    schema: fastify.jsonSchemaGenerator.generateExportJSONSchema(),
146
    config: {
147
      replyType: 'application/x-ndjson',
148
      serializer: fastNdjsonSerializer(fastJson(getItemJSONSchema.response['200'])),
149
    },
150
  }, handleGetList)
151
  fastify.get('/:id', { schema: getItemJSONSchema }, handleGetId)
788✔
152
  fastify.get('/count', { schema: fastify.jsonSchemaGenerator.generateCountJSONSchema() }, handleCount)
788✔
153

154
  if (!isView) {
788✔
155
    fastify.post(
634✔
156
      '/',
157
      { schema: fastify.jsonSchemaGenerator.generatePostJSONSchema() },
158
      handleInsertOne
159
    )
160
    fastify.post(
634✔
161
      '/validate',
162
      { schema: fastify.jsonSchemaGenerator.generateValidateJSONSchema() },
163
      handleValidate
164
    )
165
    fastify.delete(
634✔
166
      '/:id',
167
      { schema: fastify.jsonSchemaGenerator.generateDeleteJSONSchema() },
168
      handleDeleteId
169
    )
170
    fastify.delete(
634✔
171
      '/',
172
      { schema: fastify.jsonSchemaGenerator.generateDeleteListJSONSchema() },
173
      handleDeleteList
174
    )
175
    fastify.patch(
634✔
176
      '/:id',
177
      { schema: fastify.jsonSchemaGenerator.generatePatchJSONSchema() },
178
      handlePatchId
179
    )
180
    fastify.patch(
634✔
181
      '/',
182
      { schema: fastify.jsonSchemaGenerator.generatePatchManyJSONSchema() },
183
      handlePatchMany
184
    )
185
    fastify.post(
634✔
186
      '/upsert-one', { schema: fastify.jsonSchemaGenerator.generateUpsertOneJSONSchema() }, handleUpsertOne)
187
    fastify.post('/bulk', {
634✔
188
      schema: fastify.jsonSchemaGenerator.generateBulkJSONSchema(),
189
    }, handleInsertMany)
190
    fastify.patch('/bulk', {
634✔
191
      schema: fastify.jsonSchemaGenerator.generatePatchBulkJSONSchema(),
192
    }, handlePatchBulk)
193
    fastify.post(
634✔
194
      '/:id/state',
195
      { schema: fastify.jsonSchemaGenerator.generateChangeStateJSONSchema() },
196
      handleChangeStateById
197
    )
198
    fastify.post(
634✔
199
      '/state',
200
      { schema: fastify.jsonSchemaGenerator.generateChangeStateManyJSONSchema() },
201
      handleChangeStateMany
202
    )
203
  }
204

205
  fastify.setNotFoundHandler(notFoundHandler)
788✔
206
  fastify.setErrorHandler(customErrorHandler)
788✔
207
}
208

209
function handleGetList(request, reply) {
210
  if (this.customMetrics) {
154✔
211
    this.customMetrics.collectionInvocation.inc({
18✔
212
      collection_name: this.modelName,
213
      type: PROMETHEUS_OP_TYPE.FETCH,
214
    })
215
  }
216

217
  const { query, headers, crudContext, log } = request
154✔
218
  const {
219
    [QUERY]: clientQueryString,
220
    [PROJECTION]: clientProjectionString = '',
113✔
221
    [RAW_PROJECTION]: clientRawProjectionString = '',
123✔
222
    [SORT]: sortQuery,
223
    [LIMIT]: limit,
224
    [SKIP]: skip,
225
    [STATE]: state,
226
    ...otherParams
227
  } = query
154✔
228
  const { acl_rows, acl_read_columns } = headers
154✔
229

230
  const projection = resolveProjection(
154✔
231
    clientProjectionString,
232
    acl_read_columns,
233
    this.allFieldNames,
234
    clientRawProjectionString,
235
    log
236
  )
237

238
  const isTextSearchQuery = query._q && this.queryParser.isTextSearchQuery(JSON.parse(query._q))
133✔
239
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, isTextSearchQuery)
133✔
240

241
  let sort
242
  if (sortQuery) {
133✔
243
    sort = Object.fromEntries(sortQuery.toString().split(',')
16✔
244
      .map((param) => (param[0] === '-' ? [param.substr(1), -1] : [param, 1])))
30✔
245
  }
246

247
  const stateArr = state.split(',')
133✔
248
  const {
249
    replyType,
250
    serializer,
251
  } = reply.context.config
133✔
252
  reply.raw.setHeader('Content-Type', replyType)
133✔
253

254
  this.crudService.findAll(crudContext, mongoQuery, projection, sort, skip, limit, stateArr, isTextSearchQuery)
133✔
255
    .stream()
256
    .on('error', (error) => {
257
      request.log.error({ error }, 'Error during findAll stream')
×
258
      // NOTE: error from Mongo may not serialize the message, so we force it here,
259
      // we use debug level to prevent leaking potetially sensible information in logs.
260
      request.log.debug({ error: { ...error, message: error.message } }, 'Error during findAll stream with message')
×
261

262
      if (error.code === OPTIONS_INCOMPATIBILITY_ERROR_CODE) {
×
263
        reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, error.message)
×
264
        return
×
265
      }
266
      reply.getHttpError(INTERNAL_SERVER_ERROR_STATUS_CODE, error.message || 'something went wrong')
×
267
    })
268
    .pipe(this.castResultsAsStream())
269
    .pipe(serializer())
270
    .pipe(reply.raw)
271
}
272

273
async function handleGetId(request, reply) {
274
  if (this.customMetrics) {
118!
275
    this.customMetrics.collectionInvocation.inc({
×
276
      collection_name: this.modelName,
277
      type: PROMETHEUS_OP_TYPE.FETCH,
278
    })
279
  }
280

281
  const { crudContext, log } = request
118✔
282
  const docId = request.params.id
118✔
283
  const { acl_rows, acl_read_columns } = request.headers
118✔
284

285
  const {
286
    [QUERY]: clientQueryString,
287
    [PROJECTION]: clientProjectionString = '',
104✔
288
    [RAW_PROJECTION]: clientRawProjectionString = '',
108✔
289
    [STATE]: state,
290
    ...otherParams
291
  } = request.query
118✔
292

293
  const projection = resolveProjection(
118✔
294
    clientProjectionString,
295
    acl_read_columns,
296
    this.allFieldNames,
297
    clientRawProjectionString,
298
    log
299
  )
300
  const filter = resolveMongoQuery(
111✔
301
    this.queryParser,
302
    clientQueryString,
303
    acl_rows,
304
    otherParams,
305
    false
306
  )
307
  const _id = this.castCollectionId(docId)
111✔
308

309
  const stateArr = state.split(',')
111✔
310
  const doc = await this.crudService.findById(crudContext, _id, filter, projection, stateArr)
111✔
311
  if (!doc) {
111✔
312
    return reply.notFound()
26✔
313
  }
314

315
  this.castItem(doc)
85✔
316
  return doc
85✔
317
}
318

319
async function handleInsertOne(request, reply) {
320
  if (this.customMetrics) {
24✔
321
    this.customMetrics.collectionInvocation.inc({
3✔
322
      collection_name: this.modelName,
323
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
324
    })
325
  }
326

327
  const { body: doc, crudContext } = request
24✔
328

329
  this.queryParser.parseAndCastBody(doc)
24✔
330

331
  try {
24✔
332
    const insertedDoc = await this.crudService.insertOne(crudContext, doc)
24✔
333
    return { _id: insertedDoc._id }
23✔
334
  } catch (error) {
335
    if (error.code === UNIQUE_INDEX_MONGO_ERROR_CODE) {
1!
336
      request.log.error('unique index violation')
1✔
337
      return reply.getHttpError(UNIQUE_INDEX_ERROR_STATUS_CODE, error.message)
1✔
338
    }
339
    throw error
×
340
  }
341
}
342

343
// eslint-disable-next-line no-unused-vars
344
async function handleValidate(request, reply) {
345
  return { result: 'ok' }
1✔
346
}
347

348
async function handleDeleteId(request, reply) {
349
  if (this.customMetrics) {
17!
350
    this.customMetrics.collectionInvocation.inc({
×
351
      collection_name: this.modelName,
352
      type: PROMETHEUS_OP_TYPE.DELETE,
353
    })
354
  }
355

356
  const { query, headers, params, crudContext } = request
17✔
357

358
  const docId = params.id
17✔
359
  const _id = this.castCollectionId(docId)
17✔
360

361
  const {
362
    [QUERY]: clientQueryString,
363
    [STATE]: state,
364
    ...otherParams
365
  } = query
17✔
366
  const { acl_rows } = headers
17✔
367

368
  const filter = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
17✔
369

370
  const stateArr = state.split(',')
17✔
371
  const doc = await this.crudService.deleteById(crudContext, _id, filter, stateArr)
17✔
372

373
  if (!doc) {
17✔
374
    return reply.notFound()
6✔
375
  }
376

377
  // the document should not be returned:
378
  // we don't know which projection the user is able to see
379
  reply.code(204)
11✔
380
}
381

382
async function handleDeleteList(request) {
383
  if (this.customMetrics) {
13!
384
    this.customMetrics.collectionInvocation.inc({
×
385
      collection_name: this.modelName,
386
      type: PROMETHEUS_OP_TYPE.DELETE,
387
    })
388
  }
389

390
  const { query, headers, crudContext } = request
13✔
391

392
  const {
393
    [QUERY]: clientQueryString,
394
    [STATE]: state,
395
    ...otherParams
396
  } = query
13✔
397
  const { acl_rows } = headers
13✔
398

399
  const filter = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
13✔
400

401
  const stateArr = state.split(',')
13✔
402
  return this.crudService.deleteAll(crudContext, filter, stateArr)
13✔
403
}
404

405
async function handleCount(request) {
406
  if (this.customMetrics) {
14✔
407
    this.customMetrics.collectionInvocation.inc({
1✔
408
      collection_name: this.modelName,
409
      type: PROMETHEUS_OP_TYPE.FETCH,
410
    })
411
  }
412

413
  const { query, headers, crudContext } = request
14✔
414
  const {
415
    [QUERY]: clientQueryString,
416
    [STATE]: state,
417
    ...otherParams
418
  } = query
14✔
419
  const { acl_rows } = headers
14✔
420

421
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
14✔
422

423
  const stateArr = state.split(',')
14✔
424
  return this.crudService.count(crudContext, mongoQuery, stateArr)
14✔
425
}
426

427
async function handlePatchId(request, reply) {
428
  if (this.customMetrics) {
73!
429
    this.customMetrics.collectionInvocation.inc({
×
430
      collection_name: this.modelName,
431
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
432
    })
433
  }
434

435
  const { query, headers, params, crudContext, log } = request
73✔
436
  const {
437
    [QUERY]: clientQueryString,
438
    [STATE]: state,
439
    ...otherParams
440
  } = query
73✔
441
  const {
442
    acl_rows,
443
    acl_write_columns: aclWriteColumns,
444
    acl_read_columns: aclColumns = '',
72✔
445
  } = headers
73✔
446

447
  const commands = request.body
73✔
448

449
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
73✔
450

451
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
73✔
452

453
  this.queryParser.parseAndCastCommands(commands, editableFields)
73✔
454
  const projection = resolveProjection('', aclColumns, this.allFieldNames, '', log)
73✔
455

456
  const docId = params.id
73✔
457
  const _id = this.castCollectionId(docId)
73✔
458

459
  const stateArr = state.split(',')
73✔
460
  const doc = await this.crudService.patchById(crudContext, _id, commands, mongoQuery, projection, stateArr)
73✔
461

462
  if (!doc) {
72✔
463
    return reply.notFound()
17✔
464
  }
465

466
  this.castItem(doc)
55✔
467
  return doc
55✔
468
}
469

470
async function handlePatchMany(request) {
471
  if (this.customMetrics) {
28!
472
    this.customMetrics.collectionInvocation.inc({
×
473
      collection_name: this.modelName,
474
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
475
    })
476
  }
477

478
  const { query, headers, crudContext } = request
28✔
479
  const {
480
    [QUERY]: clientQueryString,
481
    [STATE]: state,
482
    ...otherParams
483
  } = query
28✔
484
  const {
485
    acl_rows,
486
    acl_write_columns: aclWriteColumns,
487
  } = headers
28✔
488

489
  const commands = request.body
28✔
490
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
28✔
491
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
28✔
492
  this.queryParser.parseAndCastCommands(commands, editableFields)
28✔
493

494
  const stateArr = state.split(',')
28✔
495
  const nModified = await this.crudService.patchMany(crudContext, commands, mongoQuery, stateArr)
28✔
496

497
  return nModified
28✔
498
}
499

500
async function handleUpsertOne(request) {
501
  if (this.customMetrics) {
22!
502
    this.customMetrics.collectionInvocation.inc({
×
503
      collection_name: this.modelName,
504
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
505
    })
506
  }
507

508
  const { query, headers, crudContext, log } = request
22✔
509
  const {
510
    [QUERY]: clientQueryString,
511
    [STATE]: state,
512
    ...otherParams
513
  } = query
22✔
514
  const {
515
    acl_rows,
516
    acl_write_columns: aclWriteColumns,
517
    acl_read_columns: aclColumns = '',
18✔
518
  } = headers
22✔
519

520
  const commands = request.body
22✔
521

522
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
22✔
523

524
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
22✔
525

526
  this.queryParser.parseAndCastCommands(commands, editableFields)
22✔
527
  const projection = resolveProjection('', aclColumns, this.allFieldNames, '', log)
22✔
528

529
  const stateArr = state.split(',')
22✔
530
  const doc = await this.crudService.upsertOne(crudContext, commands, mongoQuery, projection, stateArr)
22✔
531

532
  this.castItem(doc)
22✔
533
  return doc
22✔
534
}
535

536
async function handlePatchBulk(request) {
537
  if (this.customMetrics) {
26!
538
    this.customMetrics.collectionInvocation.inc({
×
539
      collection_name: this.modelName,
540
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
541
    })
542
  }
543

544
  const { body: filterUpdateCommands, crudContext, headers } = request
26✔
545

546
  const {
547
    acl_rows,
548
    acl_write_columns: aclWriteColumns,
549
  } = headers
26✔
550

551
  const parsedAndCastedCommands = new Array(filterUpdateCommands.length)
26✔
552
  for (let i = 0; i < filterUpdateCommands.length; i++) {
26✔
553
    const { filter, update } = filterUpdateCommands[i]
20,034✔
554
    const {
555
      _id,
556
      [QUERY]: clientQueryString,
557
      [STATE]: state,
558
      ...otherParams
559
    } = filter
20,034✔
560

561
    const commands = update
20,034✔
562

563
    const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
20,034✔
564

565
    const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
20,034✔
566

567
    this.queryParser.parseAndCastCommands(commands, editableFields)
20,034✔
568

569
    parsedAndCastedCommands[i] = {
20,034✔
570
      commands,
571
      state: state.split(','),
572
      query: mongoQuery,
573
    }
574
    if (_id) {
20,034✔
575
      parsedAndCastedCommands[i].query._id = this.castCollectionId(_id)
20,031✔
576
    }
577
  }
578

579
  const nModified = await this.crudService.patchBulk(crudContext, parsedAndCastedCommands)
26✔
580
  return nModified
26✔
581
}
582

583
async function handleInsertMany(request, reply) {
584
  if (this.customMetrics) {
18!
585
    this.customMetrics.collectionInvocation.inc({
×
586
      collection_name: this.modelName,
587
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
588
    })
589
  }
590

591
  const { body: docs, crudContext } = request
18✔
592

593
  docs.forEach(this.queryParser.parseAndCastBody)
18✔
594

595
  try {
18✔
596
    const insertedDocs = await this.crudService.insertMany(crudContext, docs)
18✔
597
    return insertedDocs.map(mapToObjectWithOnlyId)
17✔
598
  } catch (error) {
599
    if (error.code === UNIQUE_INDEX_MONGO_ERROR_CODE) {
1!
600
      request.log.error('unique index violation')
1✔
601
      return reply.getHttpError(UNIQUE_INDEX_ERROR_STATUS_CODE, error.message)
1✔
602
    }
603
    throw error
×
604
  }
605
}
606

607
async function handleChangeStateById(request, reply) {
608
  if (this.customMetrics) {
11!
609
    this.customMetrics.collectionInvocation.inc({
×
610
      collection_name: this.modelName,
611
      type: PROMETHEUS_OP_TYPE.CHANGE_STATE,
612
    })
613
  }
614

615
  const { body, crudContext, headers, query } = request
11✔
616
  const {
617
    [QUERY]: clientQueryString,
618
    ...otherParams
619
  } = query
11✔
620

621
  const { acl_rows } = headers
11✔
622
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
11✔
623

624
  const docId = request.params.id
11✔
625
  const _id = this.castCollectionId(docId)
11✔
626

627
  const doc = await this.crudService.changeStateById(crudContext, _id, body.stateTo, mongoQuery)
11✔
628

629
  if (!doc) {
11✔
630
    return reply.notFound()
4✔
631
  }
632

633
  reply.code(204)
7✔
634
}
635

636
async function handleChangeStateMany(request) {
637
  if (this.customMetrics) {
16!
638
    this.customMetrics.collectionInvocation.inc({
×
639
      collection_name: this.modelName,
640
      type: PROMETHEUS_OP_TYPE.CHANGE_STATE,
641
    })
642
  }
643

644
  const { body: filterUpdateCommands, crudContext, headers } = request
16✔
645

646
  const {
647
    acl_rows,
648
  } = headers
16✔
649

650
  const parsedAndCastedCommands = new Array(filterUpdateCommands.length)
16✔
651
  for (let i = 0; i < filterUpdateCommands.length; i++) {
16✔
652
    const {
653
      filter,
654
      stateTo,
655
    } = filterUpdateCommands[i]
20✔
656

657
    const mongoQuery = resolveMongoQuery(this.queryParser, null, acl_rows, filter, false)
20✔
658

659
    parsedAndCastedCommands[i] = {
20✔
660
      query: mongoQuery,
661
      stateTo,
662
    }
663
  }
664

665
  return this.crudService.changeStateMany(crudContext, parsedAndCastedCommands)
16✔
666
}
667

668
async function injectContextInRequest(request) {
669
  const userIdHeader = request.headers[this.userIdHeaderKey]
541✔
670
  const isUserHeaderInvalid = INVALID_USERID.includes(userIdHeader)
541✔
671

672
  let userId = 'public'
541✔
673

674
  if (userIdHeader && !isUserHeaderInvalid) {
541✔
675
    userId = userIdHeader
197✔
676
  }
677

678
  request.crudContext = {
541✔
679
    log: request.log,
680
    userId,
681
    now: new Date(),
682
  }
683
}
684

685
async function parseEncodedJsonQueryParams(logger, request) {
686
  if (request.headers.json_query_params_encoding) {
541!
687
    logger.warn('You\'re using the json_query_params_encoding header but it\'s deprecated and its support is going to be dropped in the next major release. Use json-query-params-encoding instead.')
×
688
  }
689

690
  // TODO remove request.headers.json_query_params_encoding fallback in v7.0.0
691
  const jsonQueryParamsEncoding = request.headers['json-query-params-encoding'] || request.headers.json_query_params_encoding
541✔
692
  switch (jsonQueryParamsEncoding) {
541✔
693
  case 'base64': {
694
    const queryJsonFields = [QUERY, RAW_PROJECTION]
3✔
695
    for (const field of queryJsonFields) {
3✔
696
      if (request.query[field]) {
6✔
697
        request.query[field] = Buffer.from(request.query[field], jsonQueryParamsEncoding).toString()
3✔
698
      }
699
    }
700
    break
3✔
701
  }
702
  default: break
538✔
703
  }
704
}
705

706
async function notFoundHandler(request, reply) {
707
  reply
59✔
708
    .code(404)
709
    .send({
710
      error: 'not found',
711
    })
712
}
713

714
async function customErrorHandler(error, request, reply) {
715
  if (error.statusCode === 404) {
149✔
716
    return notFoundHandler(request, reply)
53✔
717
  }
718

719
  if (error.validation?.[0]?.message === 'must NOT have additional properties') {
96✔
720
    reply.code(error.statusCode)
36✔
721
    throw new Error(`${error.message}. Property "${error.validation[0].params.additionalProperty}" is not defined in validation schema`)
36✔
722
  }
723

724
  throw error
60✔
725
}
726

727
function streamSerializer() {
728
  return JSONStream.stringify()
76✔
729
}
730

731
function fastNdjsonSerializer(stringify) {
732
  function ndjsonTransform(obj, encoding, callback) {
733
    this.push(`${stringify(obj)}\n`)
560✔
734
    callback()
560✔
735
  }
736
  return function ndjsonSerializer() {
788✔
737
    return through2.obj(ndjsonTransform)
57✔
738
  }
739
}
740

741
function resolveMongoQuery(
742
  queryParser,
743
  clientQueryString,
744
  rawAclRows,
745
  otherParams,
746
  textQuery
747
) {
748
  const mongoQuery = {
20,476✔
749
    $and: [],
750
  }
751
  if (clientQueryString) {
20,476✔
752
    const clientQuery = JSON.parse(clientQueryString)
127✔
753
    mongoQuery.$and.push(clientQuery)
127✔
754
  }
755
  if (otherParams) {
20,476!
756
    for (const key of Object.keys(otherParams)) {
20,476✔
757
      const value = otherParams[key]
150✔
758
      mongoQuery.$and.push({ [key]: value })
150✔
759
    }
760
  }
761

762
  if (rawAclRows) {
20,476✔
763
    const aclRows = JSON.parse(rawAclRows)
73✔
764
    if (rawAclRows[0] === '[') {
73✔
765
      mongoQuery.$and.push({ $and: aclRows })
69✔
766
    } else {
767
      mongoQuery.$and.push(aclRows)
4✔
768
    }
769
  }
770

771
  if (textQuery) {
20,476✔
772
    queryParser.parseAndCastTextSearchQuery(mongoQuery)
12✔
773
  } else {
774
    queryParser.parseAndCast(mongoQuery)
20,464✔
775
  }
776

777
  if (mongoQuery.$and && !mongoQuery.$and.length) {
20,476✔
778
    return { }
20,228✔
779
  }
780

781
  return mongoQuery
248✔
782
}
783

784
function resolveProjection(clientProjectionString, aclColumns, allFieldNames, rawProjection, log) {
785
  log.debug('Resolving projections')
367✔
786
  const acls = splitACLs(aclColumns)
367✔
787

788
  if (clientProjectionString && rawProjection) {
367✔
789
    log.error('Use of both _p and _rawp is not permitted')
6✔
790
    throw new BadRequestError(
6✔
791
      'Use of both _rawp and _p parameter is not allowed')
792
  }
793

794
  if (!clientProjectionString && !rawProjection) {
361✔
795
    return removeAclColumns(allFieldNames, acls)
277✔
796
  } else if (rawProjection) {
84✔
797
    return resolveRawProjectionString(rawProjection, acls, allFieldNames, log)
35✔
798
  } else if (clientProjectionString) {
49!
799
    return resolveClientProjectionString(clientProjectionString, acls)
49✔
800
  }
801
}
802

803
function resolveClientProjectionString(clientProjectionString, _acls) {
804
  const clientProjection = getClientProjection(clientProjectionString)
49✔
805
  return removeAclColumns(clientProjection, _acls)
49✔
806
}
807

808
function resolveRawProjectionString(rawProjection, _acls, allFieldNames, log) {
809
  try {
35✔
810
    checkAllowedOperators(
35✔
811
      rawProjection,
812
      rawProjectionDictionary,
813
      _acls.length > 0 ? _acls : allFieldNames, log)
35✔
814

815
    const rawProjectionObject = resolveRawProjection(rawProjection)
15✔
816
    const projection = removeAclColumnsFromRawProjection(rawProjectionObject, _acls)
15✔
817

818
    return !isEmpty(projection) ? [projection] : []
13✔
819
  } catch (errorMessage) {
820
    log.error(errorMessage.message)
22✔
821
    throw new BadRequestError(errorMessage.message)
22✔
822
  }
823
}
824

825
function splitACLs(acls) {
826
  if (acls) { return acls.split(',') }
367✔
827
  return []
335✔
828
}
829

830
function removeAclColumns(fieldsInProjection, aclColumns) {
831
  if (aclColumns.length > 0) {
339✔
832
    return fieldsInProjection.filter(field => {
30✔
833
      return aclColumns.indexOf(field) > -1
204✔
834
    })
835
  }
836

837
  return fieldsInProjection
309✔
838
}
839

840
function removeAclColumnsFromRawProjection(rawProjectionObject, aclColumns) {
841
  const isRawProjectionOverridingACLs = checkIfRawProjectionOverridesAcls(rawProjectionObject, aclColumns)
15✔
842
  if (isRawProjectionOverridingACLs) {
15✔
843
    throw Error('_rawp exclusive projection is overriding at least one acl_read_column value')
2✔
844
  }
845

846
  const rawProjectionFields = Object.keys(rawProjectionObject)
13✔
847
  const filteredFields = removeAclColumns(rawProjectionFields, aclColumns)
13✔
848

849
  return filteredFields.reduce((acc, current) => {
13✔
850
    if (rawProjectionObject[current] === 0 || rawProjectionObject[current]) {
26!
851
      acc[current] = rawProjectionObject[current]
26✔
852
    }
853
    return acc
26✔
854
  }, {})
855
}
856

857
function getClientProjection(clientProjectionString) {
858
  if (clientProjectionString) {
49!
859
    return clientProjectionString.split(',')
49✔
860
  }
861
  return []
×
862
}
863

864
function resolveRawProjection(clientRawProjectionString) {
865
  if (clientRawProjectionString) {
15!
866
    return JSON.parse(clientRawProjectionString)
15✔
867
  }
868
  return {}
×
869
}
870

871
function checkAllowedOperators(rawProjection, projectionDictionary, additionalFields, log) {
872
  if (!rawProjection) {
35!
873
    log.debug('No raw projection found: checkAllowedOperators returns true')
×
874
    return true
×
875
  }
876

877
  const { allowedOperators, notAllowedOperators } = projectionDictionary
35✔
878
  const allowedFields = [...allowedOperators]
35✔
879

880
  additionalFields.forEach(field => allowedFields.push(`$${field}`))
603✔
881

882
  log.debug({ allowedOperators: allowedFields }, 'Allowed operators for projection')
35✔
883
  log.debug({ notAllowedOperators }, 'Not allowed operators for projection')
35✔
884

885
  // to match both camelCase operators and snake mongo_systems variables
886
  const operatorsRegex = /\${1,2}[a-zA-Z_]+/g
35✔
887
  const matches = rawProjection.match(operatorsRegex)
35✔
888

889
  if (!matches) {
35✔
890
    log.debug('No operators found in raw projection: checkAllowedOperators returns true')
15✔
891
    return true
15✔
892
  }
893

894
  return !matches.some(match => {
20✔
895
    if (match.startsWith('$$')) {
28✔
896
      log.debug({ match }, 'Found $$ match in raw projection')
16✔
897
      if (notAllowedOperators.includes(match)) {
16!
898
        throw Error(`Operator ${match} is not allowed in raw projection`)
16✔
899
      }
900

901
      return notAllowedOperators.includes(match)
×
902
    }
903

904
    if (!allowedFields.includes(match)) {
12✔
905
      throw Error(`Operator ${match} is not allowed in raw projection`)
4✔
906
    }
907

908
    return !allowedFields.includes(match)
8✔
909
  })
910
}
911

912
function checkIfRawProjectionOverridesAcls(rawProjection, acls) {
913
  return Object.keys(rawProjection).some(field =>
15✔
914
    acls.includes(field) && rawProjection[field] === 0
43✔
915
  )
916
}
917

918
function mapToObjectWithOnlyId(doc) {
919
  return { _id: doc._id }
50,033✔
920
}
921

922
const internalFields = [
24✔
923
  UPDATERID,
924
  UPDATEDAT,
925
  CREATORID,
926
  CREATEDAT,
927
  __STATE__,
928
]
929
function getEditableFields(aclWriteColumns, allFieldNames) {
930
  const editableFields = aclWriteColumns ? aclWriteColumns.split(',') : allFieldNames
20,157!
931
  return editableFields.filter(ef => !internalFields.includes(ef))
423,257✔
932
}
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

© 2025 Coveralls, Inc