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

mia-platform / crud-service / 5388848496

pending completion
5388848496

Pull #111

github

web-flow
Merge 0bd41e7f9 into 01e3e34c6
Pull Request #111: fix: objectId casting now working

1015 of 1132 branches covered (89.66%)

Branch coverage included in aggregate %.

42 of 42 new or added lines in 4 files covered. (100.0%)

2066 of 2138 relevant lines covered (96.63%)

20972.12 hits per line

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

88.48
/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')
104✔
22
const ajvFormats = require('ajv-formats')
104✔
23
const ajvKeywords = require('ajv-keywords')
104✔
24

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

30
const { isEmpty } = require('ramda')
104✔
31

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')
104✔
50

51
const BadRequestError = require('./BadRequestError')
104✔
52
const { SCHEMAS_ID } = require('./schemaGetters')
104✔
53
const { validateStream, getAjvResponseValidationFunction } = require('./validatorGetters')
104✔
54

55
const BAD_REQUEST_ERROR_STATUS_CODE = 400
104✔
56
const INTERNAL_SERVER_ERROR_STATUS_CODE = 500
104✔
57
const OPTIONS_INCOMPATIBILITY_ERROR_CODE = 2
104✔
58
const UNIQUE_INDEX_ERROR_STATUS_CODE = 422
104✔
59
const UNIQUE_INDEX_MONGO_ERROR_CODE = 11000
104✔
60

61
const PROMETHEUS_OP_TYPE = {
104✔
62
  FETCH: 'fetch',
63
  INSERT_OR_UPDATE: 'insert_or_update',
64
  DELETE: 'delete',
65
  CHANGE_STATE: 'change_state',
66
}
67

68

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

82
  const {
83
    registerGetters = true,
×
84
    registerSetters = true,
×
85
    registerLookup = false,
4,122✔
86
  } = options
4,126✔
87

88
  const NESTED_SCHEMAS_BY_ID = {
4,126✔
89
    [SCHEMAS_ID.GET_LIST]: fastify.jsonSchemaGeneratorWithNested.generateGetListJSONSchema(),
90
    [SCHEMAS_ID.GET_LIST_LOOKUP]: fastify.jsonSchemaGeneratorWithNested.generateGetListLookupJSONSchema(),
91
    [SCHEMAS_ID.GET_ITEM]: fastify.jsonSchemaGeneratorWithNested.generateGetItemJSONSchema(),
92
    [SCHEMAS_ID.EXPORT]: fastify.jsonSchemaGeneratorWithNested.generateExportJSONSchema(),
93
    [SCHEMAS_ID.POST_ITEM]: fastify.jsonSchemaGeneratorWithNested.generatePostJSONSchema(),
94
    [SCHEMAS_ID.POST_BULK]: fastify.jsonSchemaGeneratorWithNested.generateBulkJSONSchema(),
95
    [SCHEMAS_ID.DELETE_ITEM]: fastify.jsonSchemaGeneratorWithNested.generateDeleteJSONSchema(),
96
    [SCHEMAS_ID.DELETE_LIST]: fastify.jsonSchemaGeneratorWithNested.generateDeleteListJSONSchema(),
97
    [SCHEMAS_ID.PATCH_ITEM]: fastify.jsonSchemaGeneratorWithNested.generatePatchJSONSchema(),
98
    [SCHEMAS_ID.PATCH_MANY]: fastify.jsonSchemaGeneratorWithNested.generatePatchManyJSONSchema(),
99
    [SCHEMAS_ID.PATCH_BULK]: fastify.jsonSchemaGeneratorWithNested.generatePatchBulkJSONSchema(),
100
    [SCHEMAS_ID.UPSERT_ONE]: fastify.jsonSchemaGeneratorWithNested.generateUpsertOneJSONSchema(),
101
    [SCHEMAS_ID.COUNT]: fastify.jsonSchemaGeneratorWithNested.generateCountJSONSchema(),
102
    [SCHEMAS_ID.VALIDATE]: fastify.jsonSchemaGeneratorWithNested.generateValidateJSONSchema(),
103
    [SCHEMAS_ID.CHANGE_STATE]: fastify.jsonSchemaGeneratorWithNested.generateChangeStateJSONSchema(),
104
    [SCHEMAS_ID.CHANGE_STATE_MANY]: fastify.jsonSchemaGeneratorWithNested.generateChangeStateManyJSONSchema(),
105
  }
106

107
  // for each collection define its dedicated validator instance
108
  const ajv = new Ajv({
4,126✔
109
    coerceTypes: true,
110
    useDefaults: true,
111
    allowUnionTypes: true,
112
    // allow properties and pattern properties to overlap -> this should help validating nested fields
113
    allowMatchingProperties: true,
114
  })
115
  ajvFormats(ajv)
4,126✔
116
  ajvKeywords(ajv, 'instanceof')
4,126✔
117
  ajv.addVocabulary(Object.values(SCHEMA_CUSTOM_KEYWORDS))
4,126✔
118

119
  fastify.setValidatorCompiler(({ schema }) => {
4,126✔
120
    const uniqueId = schema[SCHEMA_CUSTOM_KEYWORDS.UNIQUE_OPERATION_ID]
121,280✔
121
    const [collectionName, schemaId, subSchemaPath] = uniqueId.split('__MIA__')
121,280✔
122
    const nestedSchema = NESTED_SCHEMAS_BY_ID[schemaId]
121,280✔
123
    const subSchema = lget(nestedSchema, subSchemaPath)
121,280✔
124
    fastify.log.debug({ collectionName, schemaPath: subSchemaPath, schemaId }, 'collection schema info')
121,280✔
125

126
    // this is made to prevent to shows on swagger all properties with dot notation of RawObject with schema.
127
    return ajv.compile(subSchema)
121,280✔
128
  })
129

130
  fastify.addHook('preHandler', injectContextInRequest)
4,126✔
131
  fastify.addHook('preHandler', request => parseEncodedJsonQueryParams(fastify.log, request))
4,126✔
132
  fastify.setErrorHandler(customErrorHandler)
4,126✔
133

134
  if (registerGetters) {
4,126✔
135
    const getItemJSONSchema = fastify.jsonSchemaGenerator.generateGetItemJSONSchema()
4,118✔
136
    fastify.get('/', {
4,118✔
137
      schema: fastify.jsonSchemaGenerator.generateGetListJSONSchema(),
138
      config: {
139
        replyType: 'application/json',
140
        serializer: streamSerializer,
141
      },
142
    }, handleGetList)
143
    fastify.get('/export', {
4,118✔
144
      schema: fastify.jsonSchemaGenerator.generateExportJSONSchema(),
145
      config: {
146
        replyType: 'application/x-ndjson',
147
        serializer: fastNdjsonSerializer(fastJson(getItemJSONSchema.response['200'])),
148
      },
149
    }, handleGetList)
150
    fastify.get('/:id', { schema: getItemJSONSchema }, handleGetId)
4,118✔
151
    fastify.get('/count', { schema: fastify.jsonSchemaGenerator.generateCountJSONSchema() }, handleCount)
4,118✔
152
    fastify.setNotFoundHandler(notFoundHandler)
4,118✔
153
  }
154

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

206
  if (registerLookup) {
4,126✔
207
    if (!fastify.lookupProjection) { throw new Error('`fastify.lookupProjection` is undefined') }
4!
208
    fastify.get('/', {
4✔
209
      schema: fastify.jsonSchemaGenerator.generateGetListLookupJSONSchema(),
210
      config: {
211
        replyType: 'application/json',
212
        serializer: streamSerializer,
213
      },
214
    }, handleGetListLookup)
215
  }
216
}
217

218
function handleGetListLookup(request, reply) {
219
  if (this.customMetrics) {
40!
220
    this.customMetrics.collectionInvocation.inc({
40✔
221
      collection_name: this.modelName,
222
      type: PROMETHEUS_OP_TYPE.FETCH,
223
    })
224
  }
225

226
  const { query, headers, crudContext, log } = request
40✔
227

228
  const {
229
    [QUERY]: clientQueryString,
230
    [PROJECTION]: clientProjectionString = '',
32✔
231
    [SORT]: sortQuery,
232
    [LIMIT]: limit,
233
    [SKIP]: skip,
234
    [STATE]: state,
235
    ...otherParams
236
  } = query
40✔
237
  const { acl_rows, acl_read_columns } = headers
40✔
238

239
  let projection = resolveProjection(
40✔
240
    clientProjectionString,
241
    acl_read_columns,
242
    this.allFieldNames,
243
    '',
244
    log
245
  )
246

247
  projection = this.lookupProjection.filter(proj => projection.includes(Object.keys(proj)[0]))
120✔
248
  if (projection.length === 0) {
40✔
249
    reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, 'No allowed colums')
2✔
250
  }
251

252
  const LookupProjectionFieldsToOmit = this.lookupProjection.filter(field => Object.values(field).shift() === 0)
120✔
253
  projection.push(...LookupProjectionFieldsToOmit)
40✔
254

255
  const isTextSearchQuery = query._q && this.queryParser.isTextSearchQuery(JSON.parse(query._q))
40✔
256
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, isTextSearchQuery)
40✔
257
  let sort
258
  if (sortQuery) {
40✔
259
    sort = Object.fromEntries(sortQuery.toString().split(',')
4✔
260
      .map((param) => (param[0] === '-' ? [param.substr(1), -1] : [param, 1])))
4✔
261
  }
262

263
  const stateArr = state?.split(',')
40✔
264
  const {
265
    replyType,
266
    serializer,
267
  } = reply.context.config
40✔
268
  reply.raw.setHeader('Content-Type', replyType)
40✔
269
  const validate = getAjvResponseValidationFunction(request.routeSchema.response['200'].items)
40✔
270

271
  this.crudService
40✔
272
    .aggregate(crudContext, mongoQuery, projection, sort, skip, limit, stateArr, isTextSearchQuery)
273
    .stream()
274
    .on('error', (error) => {
275
      log.error({ error }, 'Error during findAll stream')
×
276
      // NOTE: error from Mongo may not serialize the message, so we force it here,
277
      // we use debug level to prevent leaking potetially sensible information in logs.
278
      log.debug({ error: { ...error, message: error.message } }, 'Error during findAll stream with message')
×
279

280
      if (error.code === OPTIONS_INCOMPATIBILITY_ERROR_CODE) {
×
281
        reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, error.message)
×
282
        return
×
283
      }
284
      reply.getHttpError(INTERNAL_SERVER_ERROR_STATUS_CODE, error.message || 'something went wrong')
×
285
    })
286
    .pipe(this.castResultsAsStream())
287
    .pipe(validateStream(validate))
288
    .pipe(serializer())
289
    .pipe(reply.raw)
290
}
291

292
function handleGetList(request, reply) {
293
  if (this.customMetrics) {
647✔
294
    this.customMetrics.collectionInvocation.inc({
81✔
295
      collection_name: this.modelName,
296
      type: PROMETHEUS_OP_TYPE.FETCH,
297
    })
298
  }
299

300
  const { query, headers, crudContext, log } = request
647✔
301
  const {
302
    [QUERY]: clientQueryString,
303
    [PROJECTION]: clientProjectionString = '',
483✔
304
    [RAW_PROJECTION]: clientRawProjectionString = '',
505✔
305
    [SORT]: sortQuery,
306
    [LIMIT]: limit,
307
    [SKIP]: skip,
308
    [STATE]: state,
309
    ...otherParams
310
  } = query
647✔
311
  const { acl_rows, acl_read_columns } = headers
647✔
312

313
  const projection = resolveProjection(
647✔
314
    clientProjectionString,
315
    acl_read_columns,
316
    this.allFieldNames,
317
    clientRawProjectionString,
318
    log
319
  )
320

321
  const isTextSearchQuery = query._q && this.queryParser.isTextSearchQuery(JSON.parse(query._q))
563✔
322
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, isTextSearchQuery)
563✔
323

324
  let sort
325
  if (sortQuery) {
563✔
326
    sort = Object.fromEntries(sortQuery.toString().split(',')
66✔
327
      .map((param) => (param[0] === '-' ? [param.substr(1), -1] : [param, 1])))
122✔
328
  }
329

330
  const stateArr = state.split(',')
563✔
331
  const {
332
    replyType,
333
    serializer,
334
  } = reply.context.config
563✔
335
  reply.raw.setHeader('Content-Type', replyType)
563✔
336
  const validate = getAjvResponseValidationFunction(request.routeSchema.response['200'].items)
563✔
337

338
  this.crudService
563✔
339
    .findAll(crudContext, mongoQuery, projection, sort, skip, limit, stateArr, isTextSearchQuery)
340
    .stream()
341
    .on('error', (error) => {
342
      request.log.error({ error }, 'Error during findAll stream')
×
343
      // NOTE: error from Mongo may not serialize the message, so we force it here,
344
      // we use debug level to prevent leaking potetially sensible information in logs.
345
      request.log.debug({ error: { ...error, message: error.message } }, 'Error during findAll stream with message')
×
346

347
      if (error.code === OPTIONS_INCOMPATIBILITY_ERROR_CODE) {
×
348
        reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, error.message)
×
349
        return
×
350
      }
351
      reply.getHttpError(INTERNAL_SERVER_ERROR_STATUS_CODE, error.message || 'something went wrong')
×
352
    })
353
    .pipe(this.castResultsAsStream())
354
    .pipe(validateStream(validate))
355
    .pipe(serializer())
356
    .pipe(reply.raw)
357
}
358

359
async function handleGetId(request, reply) {
360
  if (this.customMetrics) {
491!
361
    this.customMetrics.collectionInvocation.inc({
×
362
      collection_name: this.modelName,
363
      type: PROMETHEUS_OP_TYPE.FETCH,
364
    })
365
  }
366

367
  const { crudContext, log } = request
491✔
368
  const docId = request.params.id
491✔
369
  const { acl_rows, acl_read_columns } = request.headers
491✔
370

371
  const {
372
    [QUERY]: clientQueryString,
373
    [PROJECTION]: clientProjectionString = '',
435✔
374
    [RAW_PROJECTION]: clientRawProjectionString = '',
448✔
375
    [STATE]: state,
376
    ...otherParams
377
  } = request.query
491✔
378

379
  const projection = resolveProjection(
491✔
380
    clientProjectionString,
381
    acl_read_columns,
382
    this.allFieldNames,
383
    clientRawProjectionString,
384
    log
385
  )
386
  const filter = resolveMongoQuery(
463✔
387
    this.queryParser,
388
    clientQueryString,
389
    acl_rows,
390
    otherParams,
391
    false
392
  )
393
  const _id = this.castCollectionId(docId)
463✔
394

395
  const stateArr = state.split(',')
463✔
396
  const doc = await this.crudService.findById(crudContext, _id, filter, projection, stateArr)
463✔
397
  if (!doc) {
463✔
398
    return reply.notFound()
104✔
399
  }
400

401
  const response = this.castItem(doc)
359✔
402
  const validate = getAjvResponseValidationFunction(request.routeSchema.response['200'])
359✔
403
  validate(response)
359✔
404
  return response
359✔
405
}
406

407
async function handleInsertOne(request, reply) {
408
  if (this.customMetrics) {
98✔
409
    this.customMetrics.collectionInvocation.inc({
14✔
410
      collection_name: this.modelName,
411
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
412
    })
413
  }
414

415
  const { body: doc, crudContext } = request
98✔
416

417
  this.queryParser.parseAndCastBody(doc)
98✔
418

419
  try {
98✔
420
    const insertedDoc = await this.crudService.insertOne(crudContext, doc)
98✔
421
    return { _id: insertedDoc._id }
94✔
422
  } catch (error) {
423
    if (error.code === UNIQUE_INDEX_MONGO_ERROR_CODE) {
4!
424
      request.log.error('unique index violation')
4✔
425
      return reply.getHttpError(UNIQUE_INDEX_ERROR_STATUS_CODE, error.message)
4✔
426
    }
427
    throw error
×
428
  }
429
}
430

431
async function handleValidate() {
432
  return { result: 'ok' }
4✔
433
}
434

435
async function handleDeleteId(request, reply) {
436
  if (this.customMetrics) {
70✔
437
    this.customMetrics.collectionInvocation.inc({
2✔
438
      collection_name: this.modelName,
439
      type: PROMETHEUS_OP_TYPE.DELETE,
440
    })
441
  }
442

443
  const { query, headers, params, crudContext } = request
70✔
444

445
  const docId = params.id
70✔
446
  const _id = this.castCollectionId(docId)
70✔
447

448
  const {
449
    [QUERY]: clientQueryString,
450
    [STATE]: state,
451
    ...otherParams
452
  } = query
70✔
453
  const { acl_rows } = headers
70✔
454

455
  const filter = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
70✔
456

457
  const stateArr = state.split(',')
70✔
458
  const doc = await this.crudService.deleteById(crudContext, _id, filter, stateArr)
70✔
459

460
  if (!doc) {
70✔
461
    return reply.notFound()
24✔
462
  }
463

464
  // the document should not be returned:
465
  // we don't know which projection the user is able to see
466
  reply.code(204)
46✔
467
}
468

469
async function handleDeleteList(request) {
470
  if (this.customMetrics) {
54✔
471
    this.customMetrics.collectionInvocation.inc({
2✔
472
      collection_name: this.modelName,
473
      type: PROMETHEUS_OP_TYPE.DELETE,
474
    })
475
  }
476

477
  const { query, headers, crudContext } = request
54✔
478

479
  const {
480
    [QUERY]: clientQueryString,
481
    [STATE]: state,
482
    ...otherParams
483
  } = query
54✔
484
  const { acl_rows } = headers
54✔
485

486
  const filter = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
54✔
487

488
  const stateArr = state.split(',')
54✔
489
  return this.crudService.deleteAll(crudContext, filter, stateArr)
54✔
490
}
491

492
async function handleCount(request) {
493
  if (this.customMetrics) {
56✔
494
    this.customMetrics.collectionInvocation.inc({
4✔
495
      collection_name: this.modelName,
496
      type: PROMETHEUS_OP_TYPE.FETCH,
497
    })
498
  }
499

500
  const { query, headers, crudContext } = request
56✔
501
  const {
502
    [QUERY]: clientQueryString,
503
    [STATE]: state,
504
    ...otherParams
505
  } = query
56✔
506
  const { acl_rows } = headers
56✔
507

508
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
56✔
509

510
  const stateArr = state.split(',')
56✔
511
  return this.crudService.count(crudContext, mongoQuery, stateArr)
56✔
512
}
513

514
async function handlePatchId(request, reply) {
515
  if (this.customMetrics) {
294✔
516
    this.customMetrics.collectionInvocation.inc({
2✔
517
      collection_name: this.modelName,
518
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
519
    })
520
  }
521

522
  const { query, headers, params, crudContext, log } = request
294✔
523
  const {
524
    [QUERY]: clientQueryString,
525
    [STATE]: state,
526
    ...otherParams
527
  } = query
294✔
528
  const {
529
    acl_rows,
530
    acl_write_columns: aclWriteColumns,
531
    acl_read_columns: aclColumns = '',
290✔
532
  } = headers
294✔
533

534
  const commands = request.body
294✔
535

536
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
294✔
537

538
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
294✔
539

540
  this.queryParser.parseAndCastCommands(commands, editableFields)
294✔
541
  const projection = resolveProjection('', aclColumns, this.allFieldNames, '', log)
294✔
542

543
  const docId = params.id
294✔
544
  const _id = this.castCollectionId(docId)
294✔
545

546
  const stateArr = state.split(',')
294✔
547
  const doc = await this.crudService.patchById(crudContext, _id, commands, mongoQuery, projection, stateArr)
294✔
548

549
  if (!doc) {
290✔
550
    return reply.notFound()
68✔
551
  }
552

553
  const response = this.castItem(doc)
222✔
554
  const validate = getAjvResponseValidationFunction(request.routeSchema.response['200'])
222✔
555
  validate(response)
222✔
556
  return response
222✔
557
}
558

559
async function handlePatchMany(request) {
560
  if (this.customMetrics) {
112!
561
    this.customMetrics.collectionInvocation.inc({
×
562
      collection_name: this.modelName,
563
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
564
    })
565
  }
566

567
  const { query, headers, crudContext } = request
112✔
568
  const {
569
    [QUERY]: clientQueryString,
570
    [STATE]: state,
571
    ...otherParams
572
  } = query
112✔
573
  const {
574
    acl_rows,
575
    acl_write_columns: aclWriteColumns,
576
  } = headers
112✔
577

578
  const commands = request.body
112✔
579
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
112✔
580
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
112✔
581
  this.queryParser.parseAndCastCommands(commands, editableFields)
112✔
582

583
  const stateArr = state.split(',')
112✔
584
  const nModified = await this.crudService.patchMany(crudContext, commands, mongoQuery, stateArr)
112✔
585

586
  return nModified
112✔
587
}
588

589
async function handleUpsertOne(request) {
590
  if (this.customMetrics) {
88!
591
    this.customMetrics.collectionInvocation.inc({
×
592
      collection_name: this.modelName,
593
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
594
    })
595
  }
596

597
  const { query, headers, crudContext, log } = request
88✔
598
  const {
599
    [QUERY]: clientQueryString,
600
    [STATE]: state,
601
    ...otherParams
602
  } = query
88✔
603
  const {
604
    acl_rows,
605
    acl_write_columns: aclWriteColumns,
606
    acl_read_columns: aclColumns = '',
72✔
607
  } = headers
88✔
608

609
  const commands = request.body
88✔
610

611
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
88✔
612

613
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
88✔
614

615
  this.queryParser.parseAndCastCommands(commands, editableFields)
88✔
616
  const projection = resolveProjection('', aclColumns, this.allFieldNames, '', log)
88✔
617

618
  const stateArr = state.split(',')
88✔
619
  const doc = await this.crudService.upsertOne(crudContext, commands, mongoQuery, projection, stateArr)
88✔
620

621
  const response = this.castItem(doc)
88✔
622
  const validate = getAjvResponseValidationFunction(request.routeSchema.response['200'])
88✔
623
  validate(response)
88✔
624
  return response
88✔
625
}
626

627
async function handlePatchBulk(request) {
628
  if (this.customMetrics) {
120!
629
    this.customMetrics.collectionInvocation.inc({
×
630
      collection_name: this.modelName,
631
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
632
    })
633
  }
634

635
  const { body: filterUpdateCommands, crudContext, headers } = request
120✔
636

637
  const {
638
    acl_rows,
639
    acl_write_columns: aclWriteColumns,
640
  } = headers
120✔
641

642
  const parsedAndCastedCommands = new Array(filterUpdateCommands.length)
120✔
643
  for (let i = 0; i < filterUpdateCommands.length; i++) {
120✔
644
    const { filter, update } = filterUpdateCommands[i]
80,152✔
645
    const {
646
      _id,
647
      [QUERY]: clientQueryString,
648
      [STATE]: state,
649
      ...otherParams
650
    } = filter
80,152✔
651

652
    const commands = update
80,152✔
653

654
    const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
80,152✔
655

656
    const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
80,152✔
657

658
    this.queryParser.parseAndCastCommands(commands, editableFields)
80,152✔
659

660
    parsedAndCastedCommands[i] = {
80,152✔
661
      commands,
662
      state: state.split(','),
663
      query: mongoQuery,
664
    }
665
    if (_id) {
80,152✔
666
      parsedAndCastedCommands[i].query._id = this.castCollectionId(_id)
80,140✔
667
    }
668
  }
669

670
  const nModified = await this.crudService.patchBulk(crudContext, parsedAndCastedCommands)
120✔
671
  return nModified
120✔
672
}
673

674
async function handleInsertMany(request, reply) {
675
  if (this.customMetrics) {
80!
676
    this.customMetrics.collectionInvocation.inc({
×
677
      collection_name: this.modelName,
678
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
679
    })
680
  }
681

682
  const { body: docs, crudContext } = request
80✔
683

684
  docs.forEach(this.queryParser.parseAndCastBody)
80✔
685

686
  try {
80✔
687
    const insertedDocs = await this.crudService.insertMany(crudContext, docs)
80✔
688
    return insertedDocs.map(mapToObjectWithOnlyId)
76✔
689
  } catch (error) {
690
    if (error.code === UNIQUE_INDEX_MONGO_ERROR_CODE) {
4!
691
      request.log.error('unique index violation')
4✔
692
      return reply.getHttpError(UNIQUE_INDEX_ERROR_STATUS_CODE, error.message)
4✔
693
    }
694
    throw error
×
695
  }
696
}
697

698
async function handleChangeStateById(request, reply) {
699
  if (this.customMetrics) {
44!
700
    this.customMetrics.collectionInvocation.inc({
×
701
      collection_name: this.modelName,
702
      type: PROMETHEUS_OP_TYPE.CHANGE_STATE,
703
    })
704
  }
705

706
  const { body, crudContext, headers, query } = request
44✔
707
  const {
708
    [QUERY]: clientQueryString,
709
    ...otherParams
710
  } = query
44✔
711

712
  const { acl_rows } = headers
44✔
713
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
44✔
714

715
  const docId = request.params.id
44✔
716
  const _id = this.castCollectionId(docId)
44✔
717

718
  const doc = await this.crudService.changeStateById(crudContext, _id, body.stateTo, mongoQuery)
44✔
719

720
  if (!doc) {
44✔
721
    return reply.notFound()
16✔
722
  }
723

724
  reply.code(204)
28✔
725
}
726

727
async function handleChangeStateMany(request) {
728
  if (this.customMetrics) {
64!
729
    this.customMetrics.collectionInvocation.inc({
×
730
      collection_name: this.modelName,
731
      type: PROMETHEUS_OP_TYPE.CHANGE_STATE,
732
    })
733
  }
734

735
  const { body: filterUpdateCommands, crudContext, headers } = request
64✔
736

737
  const {
738
    acl_rows,
739
  } = headers
64✔
740

741
  const parsedAndCastedCommands = new Array(filterUpdateCommands.length)
64✔
742
  for (let i = 0; i < filterUpdateCommands.length; i++) {
64✔
743
    const {
744
      filter,
745
      stateTo,
746
    } = filterUpdateCommands[i]
80✔
747

748
    const mongoQuery = resolveMongoQuery(this.queryParser, null, acl_rows, filter, false)
80✔
749

750
    parsedAndCastedCommands[i] = {
80✔
751
      query: mongoQuery,
752
      stateTo,
753
    }
754
  }
755

756
  return this.crudService.changeStateMany(crudContext, parsedAndCastedCommands)
64✔
757
}
758

759
async function injectContextInRequest(request) {
760
  const userIdHeader = request.headers[this.userIdHeaderKey]
2,286✔
761
  const isUserHeaderInvalid = INVALID_USERID.includes(userIdHeader)
2,286✔
762

763
  let userId = 'public'
2,286✔
764

765
  if (userIdHeader && !isUserHeaderInvalid) {
2,286✔
766
    userId = userIdHeader
796✔
767
  }
768

769
  request.crudContext = {
2,286✔
770
    log: request.log,
771
    userId,
772
    now: new Date(),
773
  }
774
}
775

776
async function parseEncodedJsonQueryParams(logger, request) {
777
  if (request.headers.json_query_params_encoding) {
2,286!
778
    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.')
×
779
  }
780

781
  // TODO remove request.headers.json_query_params_encoding fallback in v7.0.0
782
  const jsonQueryParamsEncoding = request.headers['json-query-params-encoding'] || request.headers.json_query_params_encoding
2,286✔
783
  switch (jsonQueryParamsEncoding) {
2,286✔
784
  case 'base64': {
785
    const queryJsonFields = [QUERY, RAW_PROJECTION]
12✔
786
    for (const field of queryJsonFields) {
12✔
787
      if (request.query[field]) {
24✔
788
        request.query[field] = Buffer.from(request.query[field], jsonQueryParamsEncoding).toString()
12✔
789
      }
790
    }
791
    break
12✔
792
  }
793
  default: break
2,274✔
794
  }
795
}
796

797
async function notFoundHandler(request, reply) {
798
  reply
236✔
799
    .code(404)
800
    .send({
801
      error: 'not found',
802
    })
803
}
804

805
async function customErrorHandler(error, request, reply) {
806
  if (error.statusCode === 404) {
598✔
807
    return notFoundHandler(request, reply)
212✔
808
  }
809

810
  if (error.validation?.[0]?.message === 'must NOT have additional properties') {
386✔
811
    reply.code(error.statusCode)
144✔
812
    throw new Error(`${error.message}. Property "${error.validation[0].params.additionalProperty}" is not defined in validation schema`)
144✔
813
  }
814

815
  throw error
242✔
816
}
817

818
function streamSerializer() {
819
  return JSONStream.stringify()
375✔
820
}
821

822
function fastNdjsonSerializer(stringify) {
823
  function ndjsonTransform(obj, encoding, callback) {
824
    this.push(`${stringify(obj)}\n`)
2,240✔
825
    callback()
2,240✔
826
  }
827
  return function ndjsonSerializer() {
4,118✔
828
    return through2.obj(ndjsonTransform)
228✔
829
  }
830
}
831

832
function resolveMongoQuery(
833
  queryParser,
834
  clientQueryString,
835
  rawAclRows,
836
  otherParams,
837
  textQuery
838
) {
839
  const mongoQuery = {
82,016✔
840
    $and: [],
841
  }
842
  if (clientQueryString) {
82,016✔
843
    const clientQuery = JSON.parse(clientQueryString)
530✔
844
    mongoQuery.$and.push(clientQuery)
530✔
845
  }
846
  if (otherParams) {
82,016!
847
    for (const key of Object.keys(otherParams)) {
82,016✔
848
      const value = otherParams[key]
606✔
849
      mongoQuery.$and.push({ [key]: value })
606✔
850
    }
851
  }
852

853
  if (rawAclRows) {
82,016✔
854
    const aclRows = JSON.parse(rawAclRows)
298✔
855
    if (rawAclRows[0] === '[') {
298✔
856
      mongoQuery.$and.push({ $and: aclRows })
282✔
857
    } else {
858
      mongoQuery.$and.push(aclRows)
16✔
859
    }
860
  }
861

862
  if (textQuery) {
82,016✔
863
    queryParser.parseAndCastTextSearchQuery(mongoQuery)
48✔
864
  } else {
865
    queryParser.parseAndCast(mongoQuery)
81,968✔
866
  }
867

868
  if (mongoQuery.$and && !mongoQuery.$and.length) {
82,016✔
869
    return { }
80,994✔
870
  }
871

872
  return mongoQuery
1,022✔
873
}
874

875
function resolveProjection(clientProjectionString, aclColumns, allFieldNames, rawProjection, log) {
876
  log.debug('Resolving projections')
1,560✔
877
  const acls = splitACLs(aclColumns)
1,560✔
878

879
  if (clientProjectionString && rawProjection) {
1,560✔
880
    log.error('Use of both _p and _rawp is not permitted')
24✔
881
    throw new BadRequestError(
24✔
882
      'Use of both _rawp and _p parameter is not allowed')
883
  }
884

885
  if (!clientProjectionString && !rawProjection) {
1,536✔
886
    return removeAclColumns(allFieldNames, acls)
1,171✔
887
  } else if (rawProjection) {
365✔
888
    return resolveRawProjectionString(rawProjection, acls, allFieldNames, log)
161✔
889
  } else if (clientProjectionString) {
204!
890
    return resolveClientProjectionString(clientProjectionString, acls)
204✔
891
  }
892
}
893

894
function resolveClientProjectionString(clientProjectionString, _acls) {
895
  const clientProjection = getClientProjection(clientProjectionString)
204✔
896
  return removeAclColumns(clientProjection, _acls)
204✔
897
}
898

899
function resolveRawProjectionString(rawProjection, _acls, allFieldNames, log) {
900
  try {
161✔
901
    checkAllowedOperators(
161✔
902
      rawProjection,
903
      rawProjectionDictionary,
904
      _acls.length > 0 ? _acls : allFieldNames, log)
161✔
905

906
    const rawProjectionObject = resolveRawProjection(rawProjection)
81✔
907
    const projection = removeAclColumnsFromRawProjection(rawProjectionObject, _acls)
81✔
908

909
    return !isEmpty(projection) ? [projection] : []
73✔
910
  } catch (errorMessage) {
911
    log.error(errorMessage.message)
88✔
912
    throw new BadRequestError(errorMessage.message)
88✔
913
  }
914
}
915

916
function splitACLs(acls) {
917
  if (acls) { return acls.split(',') }
1,560✔
918
  return []
1,421✔
919
}
920

921
function removeAclColumns(fieldsInProjection, aclColumns) {
922
  if (aclColumns.length > 0) {
1,448✔
923
    return fieldsInProjection.filter(field => {
131✔
924
      return aclColumns.indexOf(field) > -1
837✔
925
    })
926
  }
927

928
  return fieldsInProjection
1,317✔
929
}
930

931
function removeAclColumnsFromRawProjection(rawProjectionObject, aclColumns) {
932
  const isRawProjectionOverridingACLs = checkIfRawProjectionOverridesAcls(rawProjectionObject, aclColumns)
81✔
933
  if (isRawProjectionOverridingACLs) {
81✔
934
    throw Error('_rawp exclusive projection is overriding at least one acl_read_column value')
8✔
935
  }
936

937
  const rawProjectionFields = Object.keys(rawProjectionObject)
73✔
938
  const filteredFields = removeAclColumns(rawProjectionFields, aclColumns)
73✔
939

940
  return filteredFields.reduce((acc, current) => {
73✔
941
    if (rawProjectionObject[current] === 0 || rawProjectionObject[current]) {
125!
942
      acc[current] = rawProjectionObject[current]
125✔
943
    }
944
    return acc
125✔
945
  }, {})
946
}
947

948
function getClientProjection(clientProjectionString) {
949
  if (clientProjectionString) {
204!
950
    return clientProjectionString.split(',')
204✔
951
  }
952
  return []
×
953
}
954

955
function resolveRawProjection(clientRawProjectionString) {
956
  if (clientRawProjectionString) {
81!
957
    return JSON.parse(clientRawProjectionString)
81✔
958
  }
959
  return {}
×
960
}
961

962
function checkAllowedOperators(rawProjection, projectionDictionary, additionalFields, log) {
963
  if (!rawProjection) {
161!
964
    log.debug('No raw projection found: checkAllowedOperators returns true')
×
965
    return true
×
966
  }
967

968
  const { allowedOperators, notAllowedOperators } = projectionDictionary
161✔
969
  const allowedFields = [...allowedOperators]
161✔
970

971
  additionalFields.forEach(field => allowedFields.push(`$${field}`))
2,799✔
972

973
  log.debug({ allowedOperators: allowedFields }, 'Allowed operators for projection')
161✔
974
  log.debug({ notAllowedOperators }, 'Not allowed operators for projection')
161✔
975

976
  // to match both camelCase operators and snake mongo_systems variables
977
  const operatorsRegex = /\${1,2}[a-zA-Z_]+/g
161✔
978
  const matches = rawProjection.match(operatorsRegex)
161✔
979

980
  if (!matches) {
161✔
981
    log.debug('No operators found in raw projection: checkAllowedOperators returns true')
63✔
982
    return true
63✔
983
  }
984

985
  return !matches.some(match => {
98✔
986
    if (match.startsWith('$$')) {
172✔
987
      log.debug({ match }, 'Found $$ match in raw projection')
76✔
988
      if (notAllowedOperators.includes(match)) {
76✔
989
        throw Error(`Operator ${match} is not allowed in raw projection`)
64✔
990
      }
991

992
      return notAllowedOperators.includes(match)
12✔
993
    }
994

995
    if (!allowedFields.includes(match)) {
96✔
996
      throw Error(`Operator ${match} is not allowed in raw projection`)
16✔
997
    }
998

999
    return !allowedFields.includes(match)
80✔
1000
  })
1001
}
1002

1003
function checkIfRawProjectionOverridesAcls(rawProjection, acls) {
1004
  return Object.keys(rawProjection).some(field =>
81✔
1005
    acls.includes(field) && rawProjection[field] === 0
199✔
1006
  )
1007
}
1008

1009
function mapToObjectWithOnlyId(doc) {
1010
  return { _id: doc._id }
200,148✔
1011
}
1012

1013
const internalFields = [
104✔
1014
  UPDATERID,
1015
  UPDATEDAT,
1016
  CREATORID,
1017
  CREATEDAT,
1018
  __STATE__,
1019
]
1020
function getEditableFields(aclWriteColumns, allFieldNames) {
1021
  const editableFields = aclWriteColumns ? aclWriteColumns.split(',') : allFieldNames
80,646!
1022
  return editableFields.filter(ef => !internalFields.includes(ef))
1,693,392✔
1023
}
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