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

mia-platform / crud-service / 5387509151

pending completion
5387509151

Pull #114

github

web-flow
Merge 6b1c4a0da into 01e3e34c6
Pull Request #114: Add/docs best practice

1014 of 1130 branches covered (89.73%)

Branch coverage included in aggregate %.

2050 of 2122 relevant lines covered (96.61%)

21108.91 hits per line

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

88.79
/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 lset = require('lodash.set')
104✔
27
const JSONStream = require('JSONStream')
104✔
28
const through2 = require('through2')
104✔
29
const fastJson = require('fast-json-stringify')
104✔
30

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

33

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

52
const BadRequestError = require('./BadRequestError')
104✔
53
const { JSONPath } = require('jsonpath-plus')
104✔
54
const { SCHEMAS_ID } = require('./schemaGetters')
104✔
55
const { getPathFromPointer } = require('./JSONPath.utils')
104✔
56

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

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

70

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

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

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

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

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

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

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

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

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

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

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

228
  const { query, headers, crudContext, log } = request
40✔
229

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

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

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

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

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

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

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

282
      if (error.code === OPTIONS_INCOMPATIBILITY_ERROR_CODE) {
×
283
        reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, error.message)
×
284
        return
×
285
      }
286
      reply.getHttpError(INTERNAL_SERVER_ERROR_STATUS_CODE, error.message || 'something went wrong')
×
287
    })
288
    .pipe(this.castResultsAsStream())
289
    .pipe(through2.obj((chunk, _, callback) => {
290
      const castedChunk = {
98✔
291
        ...chunk,
292
        _id: chunk._id?.toString(),
293
      }
294

295
      validate(castedChunk)
98✔
296
      callback(validate.errors, castedChunk)
98✔
297
    }))
298
    .pipe(serializer())
299
    .pipe(reply.raw)
300
}
301

302
function handleGetList(request, reply) {
303
  if (this.customMetrics) {
647✔
304
    this.customMetrics.collectionInvocation.inc({
81✔
305
      collection_name: this.modelName,
306
      type: PROMETHEUS_OP_TYPE.FETCH,
307
    })
308
  }
309

310
  const { query, headers, crudContext, log } = request
647✔
311
  const {
312
    [QUERY]: clientQueryString,
313
    [PROJECTION]: clientProjectionString = '',
483✔
314
    [RAW_PROJECTION]: clientRawProjectionString = '',
505✔
315
    [SORT]: sortQuery,
316
    [LIMIT]: limit,
317
    [SKIP]: skip,
318
    [STATE]: state,
319
    ...otherParams
320
  } = query
647✔
321
  const { acl_rows, acl_read_columns } = headers
647✔
322

323
  const projection = resolveProjection(
647✔
324
    clientProjectionString,
325
    acl_read_columns,
326
    this.allFieldNames,
327
    clientRawProjectionString,
328
    log
329
  )
330

331
  const isTextSearchQuery = query._q && this.queryParser.isTextSearchQuery(JSON.parse(query._q))
563✔
332
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, isTextSearchQuery)
563✔
333

334
  let sort
335
  if (sortQuery) {
563✔
336
    sort = Object.fromEntries(sortQuery.toString().split(',')
66✔
337
      .map((param) => (param[0] === '-' ? [param.substr(1), -1] : [param, 1])))
122✔
338
  }
339

340
  const stateArr = state.split(',')
563✔
341
  const {
342
    replyType,
343
    serializer,
344
  } = reply.context.config
563✔
345
  reply.raw.setHeader('Content-Type', replyType)
563✔
346
  const validate = getAjvResponseValidationFunction(request.routeSchema.response['200'].items)
563✔
347

348
  this.crudService
563✔
349
    .findAll(crudContext, mongoQuery, projection, sort, skip, limit, stateArr, isTextSearchQuery)
350
    .stream()
351
    .on('error', (error) => {
352
      request.log.error({ error }, 'Error during findAll stream')
×
353
      // NOTE: error from Mongo may not serialize the message, so we force it here,
354
      // we use debug level to prevent leaking potetially sensible information in logs.
355
      request.log.debug({ error: { ...error, message: error.message } }, 'Error during findAll stream with message')
×
356

357
      if (error.code === OPTIONS_INCOMPATIBILITY_ERROR_CODE) {
×
358
        reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, error.message)
×
359
        return
×
360
      }
361
      reply.getHttpError(INTERNAL_SERVER_ERROR_STATUS_CODE, error.message || 'something went wrong')
×
362
    })
363
    .pipe(this.castResultsAsStream())
364
    .pipe(through2.obj((chunk, _, callback) => {
365
      const lookupPaths = JSONPath({ json: chunk, resultType: 'pointer', path: '$..[?(@ !== null && @ !== undefined && @.label && @.value)]' })
3,314✔
366
        .map(getPathFromPointer)
367
      let castedChunk = {
3,314✔
368
        ...chunk,
369
        _id: chunk._id.toString(),
370
      }
371
      lookupPaths.forEach(path => {
3,314✔
372
        castedChunk = lset(castedChunk, `${path}.value`, castedChunk[path].value.toString())
12✔
373
      })
374

375
      validate(castedChunk)
3,314✔
376
      callback(validate.errors, castedChunk)
3,314✔
377
    }))
378
    .pipe(serializer())
379
    .pipe(reply.raw)
380
}
381

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

390
  const { crudContext, log } = request
491✔
391
  const docId = request.params.id
491✔
392
  const { acl_rows, acl_read_columns } = request.headers
491✔
393

394
  const {
395
    [QUERY]: clientQueryString,
396
    [PROJECTION]: clientProjectionString = '',
435✔
397
    [RAW_PROJECTION]: clientRawProjectionString = '',
448✔
398
    [STATE]: state,
399
    ...otherParams
400
  } = request.query
491✔
401

402
  const projection = resolveProjection(
491✔
403
    clientProjectionString,
404
    acl_read_columns,
405
    this.allFieldNames,
406
    clientRawProjectionString,
407
    log
408
  )
409
  const filter = resolveMongoQuery(
463✔
410
    this.queryParser,
411
    clientQueryString,
412
    acl_rows,
413
    otherParams,
414
    false
415
  )
416
  const _id = this.castCollectionId(docId)
463✔
417

418
  const stateArr = state.split(',')
463✔
419
  const doc = await this.crudService.findById(crudContext, _id, filter, projection, stateArr)
463✔
420
  if (!doc) {
463✔
421
    return reply.notFound()
104✔
422
  }
423

424
  const response = this.castItem(doc)
359✔
425
  const validate = getAjvResponseValidationFunction(request.routeSchema.response['200'])
359✔
426
  validate(response)
359✔
427
  return response
359✔
428
}
429

430
async function handleInsertOne(request, reply) {
431
  if (this.customMetrics) {
98✔
432
    this.customMetrics.collectionInvocation.inc({
14✔
433
      collection_name: this.modelName,
434
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
435
    })
436
  }
437

438
  const { body: doc, crudContext } = request
98✔
439

440
  this.queryParser.parseAndCastBody(doc)
98✔
441

442
  try {
98✔
443
    const insertedDoc = await this.crudService.insertOne(crudContext, doc)
98✔
444
    return { _id: insertedDoc._id }
94✔
445
  } catch (error) {
446
    if (error.code === UNIQUE_INDEX_MONGO_ERROR_CODE) {
4!
447
      request.log.error('unique index violation')
4✔
448
      return reply.getHttpError(UNIQUE_INDEX_ERROR_STATUS_CODE, error.message)
4✔
449
    }
450
    throw error
×
451
  }
452
}
453

454
async function handleValidate() {
455
  return { result: 'ok' }
4✔
456
}
457

458
async function handleDeleteId(request, reply) {
459
  if (this.customMetrics) {
70✔
460
    this.customMetrics.collectionInvocation.inc({
2✔
461
      collection_name: this.modelName,
462
      type: PROMETHEUS_OP_TYPE.DELETE,
463
    })
464
  }
465

466
  const { query, headers, params, crudContext } = request
70✔
467

468
  const docId = params.id
70✔
469
  const _id = this.castCollectionId(docId)
70✔
470

471
  const {
472
    [QUERY]: clientQueryString,
473
    [STATE]: state,
474
    ...otherParams
475
  } = query
70✔
476
  const { acl_rows } = headers
70✔
477

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

480
  const stateArr = state.split(',')
70✔
481
  const doc = await this.crudService.deleteById(crudContext, _id, filter, stateArr)
70✔
482

483
  if (!doc) {
70✔
484
    return reply.notFound()
24✔
485
  }
486

487
  // the document should not be returned:
488
  // we don't know which projection the user is able to see
489
  reply.code(204)
46✔
490
}
491

492
async function handleDeleteList(request) {
493
  if (this.customMetrics) {
54✔
494
    this.customMetrics.collectionInvocation.inc({
2✔
495
      collection_name: this.modelName,
496
      type: PROMETHEUS_OP_TYPE.DELETE,
497
    })
498
  }
499

500
  const { query, headers, crudContext } = request
54✔
501

502
  const {
503
    [QUERY]: clientQueryString,
504
    [STATE]: state,
505
    ...otherParams
506
  } = query
54✔
507
  const { acl_rows } = headers
54✔
508

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

511
  const stateArr = state.split(',')
54✔
512
  return this.crudService.deleteAll(crudContext, filter, stateArr)
54✔
513
}
514

515
async function handleCount(request) {
516
  if (this.customMetrics) {
56✔
517
    this.customMetrics.collectionInvocation.inc({
4✔
518
      collection_name: this.modelName,
519
      type: PROMETHEUS_OP_TYPE.FETCH,
520
    })
521
  }
522

523
  const { query, headers, crudContext } = request
56✔
524
  const {
525
    [QUERY]: clientQueryString,
526
    [STATE]: state,
527
    ...otherParams
528
  } = query
56✔
529
  const { acl_rows } = headers
56✔
530

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

533
  const stateArr = state.split(',')
56✔
534
  return this.crudService.count(crudContext, mongoQuery, stateArr)
56✔
535
}
536

537
async function handlePatchId(request, reply) {
538
  if (this.customMetrics) {
294✔
539
    this.customMetrics.collectionInvocation.inc({
2✔
540
      collection_name: this.modelName,
541
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
542
    })
543
  }
544

545
  const { query, headers, params, crudContext, log } = request
294✔
546
  const {
547
    [QUERY]: clientQueryString,
548
    [STATE]: state,
549
    ...otherParams
550
  } = query
294✔
551
  const {
552
    acl_rows,
553
    acl_write_columns: aclWriteColumns,
554
    acl_read_columns: aclColumns = '',
290✔
555
  } = headers
294✔
556

557
  const commands = request.body
294✔
558

559
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
294✔
560

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

563
  this.queryParser.parseAndCastCommands(commands, editableFields)
294✔
564
  const projection = resolveProjection('', aclColumns, this.allFieldNames, '', log)
294✔
565

566
  const docId = params.id
294✔
567
  const _id = this.castCollectionId(docId)
294✔
568

569
  const stateArr = state.split(',')
294✔
570
  const doc = await this.crudService.patchById(crudContext, _id, commands, mongoQuery, projection, stateArr)
294✔
571

572
  if (!doc) {
290✔
573
    return reply.notFound()
68✔
574
  }
575

576
  const response = this.castItem(doc)
222✔
577
  const validate = getAjvResponseValidationFunction(request.routeSchema.response['200'])
222✔
578
  validate(response)
222✔
579
  return response
222✔
580
}
581

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

590
  const { query, headers, crudContext } = request
112✔
591
  const {
592
    [QUERY]: clientQueryString,
593
    [STATE]: state,
594
    ...otherParams
595
  } = query
112✔
596
  const {
597
    acl_rows,
598
    acl_write_columns: aclWriteColumns,
599
  } = headers
112✔
600

601
  const commands = request.body
112✔
602
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
112✔
603
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
112✔
604
  this.queryParser.parseAndCastCommands(commands, editableFields)
112✔
605

606
  const stateArr = state.split(',')
112✔
607
  const nModified = await this.crudService.patchMany(crudContext, commands, mongoQuery, stateArr)
112✔
608

609
  return nModified
112✔
610
}
611

612
async function handleUpsertOne(request) {
613
  if (this.customMetrics) {
88!
614
    this.customMetrics.collectionInvocation.inc({
×
615
      collection_name: this.modelName,
616
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
617
    })
618
  }
619

620
  const { query, headers, crudContext, log } = request
88✔
621
  const {
622
    [QUERY]: clientQueryString,
623
    [STATE]: state,
624
    ...otherParams
625
  } = query
88✔
626
  const {
627
    acl_rows,
628
    acl_write_columns: aclWriteColumns,
629
    acl_read_columns: aclColumns = '',
72✔
630
  } = headers
88✔
631

632
  const commands = request.body
88✔
633

634
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
88✔
635

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

638
  this.queryParser.parseAndCastCommands(commands, editableFields)
88✔
639
  const projection = resolveProjection('', aclColumns, this.allFieldNames, '', log)
88✔
640

641
  const stateArr = state.split(',')
88✔
642
  const doc = await this.crudService.upsertOne(crudContext, commands, mongoQuery, projection, stateArr)
88✔
643

644
  const response = this.castItem(doc)
88✔
645
  const validate = getAjvResponseValidationFunction(request.routeSchema.response['200'])
88✔
646
  validate(response)
88✔
647
  return response
88✔
648
}
649

650
async function handlePatchBulk(request) {
651
  if (this.customMetrics) {
120!
652
    this.customMetrics.collectionInvocation.inc({
×
653
      collection_name: this.modelName,
654
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
655
    })
656
  }
657

658
  const { body: filterUpdateCommands, crudContext, headers } = request
120✔
659

660
  const {
661
    acl_rows,
662
    acl_write_columns: aclWriteColumns,
663
  } = headers
120✔
664

665
  const parsedAndCastedCommands = new Array(filterUpdateCommands.length)
120✔
666
  for (let i = 0; i < filterUpdateCommands.length; i++) {
120✔
667
    const { filter, update } = filterUpdateCommands[i]
80,152✔
668
    const {
669
      _id,
670
      [QUERY]: clientQueryString,
671
      [STATE]: state,
672
      ...otherParams
673
    } = filter
80,152✔
674

675
    const commands = update
80,152✔
676

677
    const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
80,152✔
678

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

681
    this.queryParser.parseAndCastCommands(commands, editableFields)
80,152✔
682

683
    parsedAndCastedCommands[i] = {
80,152✔
684
      commands,
685
      state: state.split(','),
686
      query: mongoQuery,
687
    }
688
    if (_id) {
80,152✔
689
      parsedAndCastedCommands[i].query._id = this.castCollectionId(_id)
80,140✔
690
    }
691
  }
692

693
  const nModified = await this.crudService.patchBulk(crudContext, parsedAndCastedCommands)
120✔
694
  return nModified
120✔
695
}
696

697
async function handleInsertMany(request, reply) {
698
  if (this.customMetrics) {
80!
699
    this.customMetrics.collectionInvocation.inc({
×
700
      collection_name: this.modelName,
701
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
702
    })
703
  }
704

705
  const { body: docs, crudContext } = request
80✔
706

707
  docs.forEach(this.queryParser.parseAndCastBody)
80✔
708

709
  try {
80✔
710
    const insertedDocs = await this.crudService.insertMany(crudContext, docs)
80✔
711
    return insertedDocs.map(mapToObjectWithOnlyId)
76✔
712
  } catch (error) {
713
    if (error.code === UNIQUE_INDEX_MONGO_ERROR_CODE) {
4!
714
      request.log.error('unique index violation')
4✔
715
      return reply.getHttpError(UNIQUE_INDEX_ERROR_STATUS_CODE, error.message)
4✔
716
    }
717
    throw error
×
718
  }
719
}
720

721
async function handleChangeStateById(request, reply) {
722
  if (this.customMetrics) {
44!
723
    this.customMetrics.collectionInvocation.inc({
×
724
      collection_name: this.modelName,
725
      type: PROMETHEUS_OP_TYPE.CHANGE_STATE,
726
    })
727
  }
728

729
  const { body, crudContext, headers, query } = request
44✔
730
  const {
731
    [QUERY]: clientQueryString,
732
    ...otherParams
733
  } = query
44✔
734

735
  const { acl_rows } = headers
44✔
736
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
44✔
737

738
  const docId = request.params.id
44✔
739
  const _id = this.castCollectionId(docId)
44✔
740

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

743
  if (!doc) {
44✔
744
    return reply.notFound()
16✔
745
  }
746

747
  reply.code(204)
28✔
748
}
749

750
async function handleChangeStateMany(request) {
751
  if (this.customMetrics) {
64!
752
    this.customMetrics.collectionInvocation.inc({
×
753
      collection_name: this.modelName,
754
      type: PROMETHEUS_OP_TYPE.CHANGE_STATE,
755
    })
756
  }
757

758
  const { body: filterUpdateCommands, crudContext, headers } = request
64✔
759

760
  const {
761
    acl_rows,
762
  } = headers
64✔
763

764
  const parsedAndCastedCommands = new Array(filterUpdateCommands.length)
64✔
765
  for (let i = 0; i < filterUpdateCommands.length; i++) {
64✔
766
    const {
767
      filter,
768
      stateTo,
769
    } = filterUpdateCommands[i]
80✔
770

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

773
    parsedAndCastedCommands[i] = {
80✔
774
      query: mongoQuery,
775
      stateTo,
776
    }
777
  }
778

779
  return this.crudService.changeStateMany(crudContext, parsedAndCastedCommands)
64✔
780
}
781

782
async function injectContextInRequest(request) {
783
  const userIdHeader = request.headers[this.userIdHeaderKey]
2,286✔
784
  const isUserHeaderInvalid = INVALID_USERID.includes(userIdHeader)
2,286✔
785

786
  let userId = 'public'
2,286✔
787

788
  if (userIdHeader && !isUserHeaderInvalid) {
2,286✔
789
    userId = userIdHeader
796✔
790
  }
791

792
  request.crudContext = {
2,286✔
793
    log: request.log,
794
    userId,
795
    now: new Date(),
796
  }
797
}
798

799
async function parseEncodedJsonQueryParams(logger, request) {
800
  if (request.headers.json_query_params_encoding) {
2,286!
801
    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.')
×
802
  }
803

804
  // TODO remove request.headers.json_query_params_encoding fallback in v7.0.0
805
  const jsonQueryParamsEncoding = request.headers['json-query-params-encoding'] || request.headers.json_query_params_encoding
2,286✔
806
  switch (jsonQueryParamsEncoding) {
2,286✔
807
  case 'base64': {
808
    const queryJsonFields = [QUERY, RAW_PROJECTION]
12✔
809
    for (const field of queryJsonFields) {
12✔
810
      if (request.query[field]) {
24✔
811
        request.query[field] = Buffer.from(request.query[field], jsonQueryParamsEncoding).toString()
12✔
812
      }
813
    }
814
    break
12✔
815
  }
816
  default: break
2,274✔
817
  }
818
}
819

820
async function notFoundHandler(request, reply) {
821
  reply
236✔
822
    .code(404)
823
    .send({
824
      error: 'not found',
825
    })
826
}
827

828
async function customErrorHandler(error, request, reply) {
829
  if (error.statusCode === 404) {
598✔
830
    return notFoundHandler(request, reply)
212✔
831
  }
832

833
  if (error.validation?.[0]?.message === 'must NOT have additional properties') {
386✔
834
    reply.code(error.statusCode)
140✔
835
    throw new Error(`${error.message}. Property "${error.validation[0].params.additionalProperty}" is not defined in validation schema`)
140✔
836
  }
837

838
  throw error
246✔
839
}
840

841
function streamSerializer() {
842
  return JSONStream.stringify()
375✔
843
}
844

845
function fastNdjsonSerializer(stringify) {
846
  function ndjsonTransform(obj, encoding, callback) {
847
    this.push(`${stringify(obj)}\n`)
2,240✔
848
    callback()
2,240✔
849
  }
850
  return function ndjsonSerializer() {
4,118✔
851
    return through2.obj(ndjsonTransform)
228✔
852
  }
853
}
854

855
function resolveMongoQuery(
856
  queryParser,
857
  clientQueryString,
858
  rawAclRows,
859
  otherParams,
860
  textQuery
861
) {
862
  const mongoQuery = {
82,016✔
863
    $and: [],
864
  }
865
  if (clientQueryString) {
82,016✔
866
    const clientQuery = JSON.parse(clientQueryString)
530✔
867
    mongoQuery.$and.push(clientQuery)
530✔
868
  }
869
  if (otherParams) {
82,016!
870
    for (const key of Object.keys(otherParams)) {
82,016✔
871
      const value = otherParams[key]
606✔
872
      mongoQuery.$and.push({ [key]: value })
606✔
873
    }
874
  }
875

876
  if (rawAclRows) {
82,016✔
877
    const aclRows = JSON.parse(rawAclRows)
298✔
878
    if (rawAclRows[0] === '[') {
298✔
879
      mongoQuery.$and.push({ $and: aclRows })
282✔
880
    } else {
881
      mongoQuery.$and.push(aclRows)
16✔
882
    }
883
  }
884

885
  if (textQuery) {
82,016✔
886
    queryParser.parseAndCastTextSearchQuery(mongoQuery)
48✔
887
  } else {
888
    queryParser.parseAndCast(mongoQuery)
81,968✔
889
  }
890

891
  if (mongoQuery.$and && !mongoQuery.$and.length) {
82,016✔
892
    return { }
80,994✔
893
  }
894

895
  return mongoQuery
1,022✔
896
}
897

898
function resolveProjection(clientProjectionString, aclColumns, allFieldNames, rawProjection, log) {
899
  log.debug('Resolving projections')
1,560✔
900
  const acls = splitACLs(aclColumns)
1,560✔
901

902
  if (clientProjectionString && rawProjection) {
1,560✔
903
    log.error('Use of both _p and _rawp is not permitted')
24✔
904
    throw new BadRequestError(
24✔
905
      'Use of both _rawp and _p parameter is not allowed')
906
  }
907

908
  if (!clientProjectionString && !rawProjection) {
1,536✔
909
    return removeAclColumns(allFieldNames, acls)
1,171✔
910
  } else if (rawProjection) {
365✔
911
    return resolveRawProjectionString(rawProjection, acls, allFieldNames, log)
161✔
912
  } else if (clientProjectionString) {
204!
913
    return resolveClientProjectionString(clientProjectionString, acls)
204✔
914
  }
915
}
916

917
function resolveClientProjectionString(clientProjectionString, _acls) {
918
  const clientProjection = getClientProjection(clientProjectionString)
204✔
919
  return removeAclColumns(clientProjection, _acls)
204✔
920
}
921

922
function resolveRawProjectionString(rawProjection, _acls, allFieldNames, log) {
923
  try {
161✔
924
    checkAllowedOperators(
161✔
925
      rawProjection,
926
      rawProjectionDictionary,
927
      _acls.length > 0 ? _acls : allFieldNames, log)
161✔
928

929
    const rawProjectionObject = resolveRawProjection(rawProjection)
81✔
930
    const projection = removeAclColumnsFromRawProjection(rawProjectionObject, _acls)
81✔
931

932
    return !isEmpty(projection) ? [projection] : []
73✔
933
  } catch (errorMessage) {
934
    log.error(errorMessage.message)
88✔
935
    throw new BadRequestError(errorMessage.message)
88✔
936
  }
937
}
938

939
function splitACLs(acls) {
940
  if (acls) { return acls.split(',') }
1,560✔
941
  return []
1,421✔
942
}
943

944
function removeAclColumns(fieldsInProjection, aclColumns) {
945
  if (aclColumns.length > 0) {
1,448✔
946
    return fieldsInProjection.filter(field => {
131✔
947
      return aclColumns.indexOf(field) > -1
837✔
948
    })
949
  }
950

951
  return fieldsInProjection
1,317✔
952
}
953

954
function removeAclColumnsFromRawProjection(rawProjectionObject, aclColumns) {
955
  const isRawProjectionOverridingACLs = checkIfRawProjectionOverridesAcls(rawProjectionObject, aclColumns)
81✔
956
  if (isRawProjectionOverridingACLs) {
81✔
957
    throw Error('_rawp exclusive projection is overriding at least one acl_read_column value')
8✔
958
  }
959

960
  const rawProjectionFields = Object.keys(rawProjectionObject)
73✔
961
  const filteredFields = removeAclColumns(rawProjectionFields, aclColumns)
73✔
962

963
  return filteredFields.reduce((acc, current) => {
73✔
964
    if (rawProjectionObject[current] === 0 || rawProjectionObject[current]) {
125!
965
      acc[current] = rawProjectionObject[current]
125✔
966
    }
967
    return acc
125✔
968
  }, {})
969
}
970

971
function getClientProjection(clientProjectionString) {
972
  if (clientProjectionString) {
204!
973
    return clientProjectionString.split(',')
204✔
974
  }
975
  return []
×
976
}
977

978
function resolveRawProjection(clientRawProjectionString) {
979
  if (clientRawProjectionString) {
81!
980
    return JSON.parse(clientRawProjectionString)
81✔
981
  }
982
  return {}
×
983
}
984

985
function checkAllowedOperators(rawProjection, projectionDictionary, additionalFields, log) {
986
  if (!rawProjection) {
161!
987
    log.debug('No raw projection found: checkAllowedOperators returns true')
×
988
    return true
×
989
  }
990

991
  const { allowedOperators, notAllowedOperators } = projectionDictionary
161✔
992
  const allowedFields = [...allowedOperators]
161✔
993

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

996
  log.debug({ allowedOperators: allowedFields }, 'Allowed operators for projection')
161✔
997
  log.debug({ notAllowedOperators }, 'Not allowed operators for projection')
161✔
998

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

1003
  if (!matches) {
161✔
1004
    log.debug('No operators found in raw projection: checkAllowedOperators returns true')
63✔
1005
    return true
63✔
1006
  }
1007

1008
  return !matches.some(match => {
98✔
1009
    if (match.startsWith('$$')) {
172✔
1010
      log.debug({ match }, 'Found $$ match in raw projection')
76✔
1011
      if (notAllowedOperators.includes(match)) {
76✔
1012
        throw Error(`Operator ${match} is not allowed in raw projection`)
64✔
1013
      }
1014

1015
      return notAllowedOperators.includes(match)
12✔
1016
    }
1017

1018
    if (!allowedFields.includes(match)) {
96✔
1019
      throw Error(`Operator ${match} is not allowed in raw projection`)
16✔
1020
    }
1021

1022
    return !allowedFields.includes(match)
80✔
1023
  })
1024
}
1025

1026
function checkIfRawProjectionOverridesAcls(rawProjection, acls) {
1027
  return Object.keys(rawProjection).some(field =>
81✔
1028
    acls.includes(field) && rawProjection[field] === 0
199✔
1029
  )
1030
}
1031

1032
function mapToObjectWithOnlyId(doc) {
1033
  return { _id: doc._id }
200,148✔
1034
}
1035

1036
const internalFields = [
104✔
1037
  UPDATERID,
1038
  UPDATEDAT,
1039
  CREATORID,
1040
  CREATEDAT,
1041
  __STATE__,
1042
]
1043
function getEditableFields(aclWriteColumns, allFieldNames) {
1044
  const editableFields = aclWriteColumns ? aclWriteColumns.split(',') : allFieldNames
80,646!
1045
  return editableFields.filter(ef => !internalFields.includes(ef))
1,693,392✔
1046
}
1047

1048
function getAjvResponseValidationFunction(schema) {
1049
  // We need this custom validator to remove properties without breaking other tests and avoid
1050
  // unwanted behaviors
1051
  const ajv = new Ajv({
1,272✔
1052
    coerceTypes: true,
1053
    useDefaults: true,
1054
    removeAdditional: true,
1055
    allowUnionTypes: true,
1056
  })
1057
  ajvFormats(ajv)
1,272✔
1058
  ajvKeywords(ajv, 'instanceof')
1,272✔
1059
  ajv.addVocabulary(Object.values(SCHEMA_CUSTOM_KEYWORDS))
1,272✔
1060
  return ajv.compile(schema)
1,272✔
1061
}
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