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

mia-platform / crud-service / 5265497397

pending completion
5265497397

Pull #92

github

web-flow
Merge 325f32589 into 9a6116684
Pull Request #92: Feat/writable views

956 of 1073 branches covered (89.1%)

Branch coverage included in aggregate %.

168 of 168 new or added lines in 5 files covered. (100.0%)

1942 of 2014 relevant lines covered (96.43%)

16150.69 hits per line

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

88.25
/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')
100✔
22
const ajvFormats = require('ajv-formats')
100✔
23

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

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

31
const { SCHEMAS_ID } = require('./JSONSchemaGenerator')
100✔
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')
100✔
50

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

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

59
const PROMETHEUS_OP_TYPE = {
100✔
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, options) {
100✔
69
  if (!fastify.crudService) { throw new Error('`fastify.crudService` is undefined') }
4,054!
70
  if (!fastify.queryParser) { throw new Error('`fastify.queryParser` is undefined') }
4,054!
71
  if (!fastify.castCollectionId) { throw new Error('`fastify.castCollectionId` is undefined') }
4,054!
72
  if (!fastify.castResultsAsStream) { throw new Error('`fastify.castResultsAsStream` is undefined') }
4,054!
73
  if (!fastify.castItem) { throw new Error('`fastify.castItem` is undefined') }
4,054!
74
  if (!fastify.allFieldNames) { throw new Error('`fastify.allFieldNames` is undefined') }
4,054!
75
  if (!fastify.jsonSchemaGenerator) { throw new Error('`fastify.jsonSchemaGenerator` is undefined') }
4,054!
76
  if (!fastify.jsonSchemaGeneratorWithNested) { throw new Error('`fastify.jsonSchemaGeneratorWithNested` is undefined') }
4,054!
77
  if (!fastify.userIdHeaderKey) { throw new Error('`fastify.userIdHeaderKey` is undefined') }
4,054!
78
  if (!fastify.modelName) { throw new Error('`fastify.modelName` is undefined') }
4,054!
79

80
  const {
81
    registerGetters = true,
×
82
    registerSetters = true,
×
83
    registerLookup = false,
4,050✔
84
  } = options
4,054✔
85

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

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

115
  fastify.setValidatorCompiler(({ schema }) => {
4,054✔
116
    const uniqueId = schema[SCHEMA_CUSTOM_KEYWORDS.UNIQUE_OPERATION_ID]
119,168✔
117
    const [collectionName, schemaId, subSchemaPath] = uniqueId.split('__MIA__')
119,168✔
118
    const nestedSchema = NESTED_SCHEMAS_BY_ID[schemaId]
119,168✔
119
    const subSchema = lget(nestedSchema, subSchemaPath)
119,168✔
120
    fastify.log.debug({ collectionName, schemaPath: subSchemaPath, schemaId }, 'collection schema info')
119,168✔
121

122
    // this is made to prevent to shows on swagger all properties with dot notation of RawObject with schema.
123
    return ajv.compile(subSchema)
119,168✔
124
  })
125

126
  fastify.addHook('preHandler', injectContextInRequest)
4,054✔
127
  fastify.addHook('preHandler', request => parseEncodedJsonQueryParams(fastify.log, request))
4,054✔
128
  fastify.setErrorHandler(customErrorHandler)
4,054✔
129

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

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

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

214
function handleGetListLookup(request, reply) {
215
  if (this.customMetrics) {
40!
216
    this.customMetrics.collectionInvocation.inc({
40✔
217
      collection_name: this.modelName,
218
      type: PROMETHEUS_OP_TYPE.FETCH,
219
    })
220
  }
221

222
  const { query, headers, crudContext, log } = request
40✔
223

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

235
  let projection = resolveProjection(
40✔
236
    clientProjectionString,
237
    acl_read_columns,
238
    this.allFieldNames,
239
    '',
240
    log
241
  )
242

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

248
  const LookupProjectionFieldsToOmit = this.lookupProjection.filter(field => Object.values(field).shift() === 0)
120✔
249
  projection.push(...LookupProjectionFieldsToOmit)
40✔
250

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

259
  const stateArr = state?.split(',')
40✔
260
  const {
261
    replyType,
262
    serializer,
263
  } = reply.context.config
40✔
264
  reply.raw.setHeader('Content-Type', replyType)
40✔
265

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

274
      if (error.code === OPTIONS_INCOMPATIBILITY_ERROR_CODE) {
×
275
        reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, error.message)
×
276
        return
×
277
      }
278
      reply.getHttpError(INTERNAL_SERVER_ERROR_STATUS_CODE, error.message || 'something went wrong')
×
279
    })
280
    .pipe(this.castResultsAsStream())
281
    .pipe(serializer())
282
    .pipe(reply.raw)
283
}
284

285
function handleGetList(request, reply) {
286
  if (this.customMetrics) {
643✔
287
    this.customMetrics.collectionInvocation.inc({
81✔
288
      collection_name: this.modelName,
289
      type: PROMETHEUS_OP_TYPE.FETCH,
290
    })
291
  }
292

293
  const { query, headers, crudContext, log } = request
643✔
294
  const {
295
    [QUERY]: clientQueryString,
296
    [PROJECTION]: clientProjectionString = '',
479✔
297
    [RAW_PROJECTION]: clientRawProjectionString = '',
501✔
298
    [SORT]: sortQuery,
299
    [LIMIT]: limit,
300
    [SKIP]: skip,
301
    [STATE]: state,
302
    ...otherParams
303
  } = query
643✔
304
  const { acl_rows, acl_read_columns } = headers
643✔
305

306
  const projection = resolveProjection(
643✔
307
    clientProjectionString,
308
    acl_read_columns,
309
    this.allFieldNames,
310
    clientRawProjectionString,
311
    log
312
  )
313

314
  const isTextSearchQuery = query._q && this.queryParser.isTextSearchQuery(JSON.parse(query._q))
559✔
315
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, isTextSearchQuery)
559✔
316

317
  let sort
318
  if (sortQuery) {
559✔
319
    sort = Object.fromEntries(sortQuery.toString().split(',')
66✔
320
      .map((param) => (param[0] === '-' ? [param.substr(1), -1] : [param, 1])))
122✔
321
  }
322

323
  const stateArr = state.split(',')
559✔
324
  const {
325
    replyType,
326
    serializer,
327
  } = reply.context.config
559✔
328
  reply.raw.setHeader('Content-Type', replyType)
559✔
329

330
  this.crudService.findAll(crudContext, mongoQuery, projection, sort, skip, limit, stateArr, isTextSearchQuery)
559✔
331
    .stream()
332
    .on('error', (error) => {
333
      request.log.error({ error }, 'Error during findAll stream')
×
334
      // NOTE: error from Mongo may not serialize the message, so we force it here,
335
      // we use debug level to prevent leaking potetially sensible information in logs.
336
      request.log.debug({ error: { ...error, message: error.message } }, 'Error during findAll stream with message')
×
337

338
      if (error.code === OPTIONS_INCOMPATIBILITY_ERROR_CODE) {
×
339
        reply.getHttpError(BAD_REQUEST_ERROR_STATUS_CODE, error.message)
×
340
        return
×
341
      }
342
      reply.getHttpError(INTERNAL_SERVER_ERROR_STATUS_CODE, error.message || 'something went wrong')
×
343
    })
344
    .pipe(this.castResultsAsStream())
345
    .pipe(serializer())
346
    .pipe(reply.raw)
347
}
348

349
async function handleGetId(request, reply) {
350
  if (this.customMetrics) {
491!
351
    this.customMetrics.collectionInvocation.inc({
×
352
      collection_name: this.modelName,
353
      type: PROMETHEUS_OP_TYPE.FETCH,
354
    })
355
  }
356

357
  const { crudContext, log } = request
491✔
358
  const docId = request.params.id
491✔
359
  const { acl_rows, acl_read_columns } = request.headers
491✔
360

361
  const {
362
    [QUERY]: clientQueryString,
363
    [PROJECTION]: clientProjectionString = '',
435✔
364
    [RAW_PROJECTION]: clientRawProjectionString = '',
448✔
365
    [STATE]: state,
366
    ...otherParams
367
  } = request.query
491✔
368

369
  const projection = resolveProjection(
491✔
370
    clientProjectionString,
371
    acl_read_columns,
372
    this.allFieldNames,
373
    clientRawProjectionString,
374
    log
375
  )
376
  const filter = resolveMongoQuery(
463✔
377
    this.queryParser,
378
    clientQueryString,
379
    acl_rows,
380
    otherParams,
381
    false
382
  )
383
  const _id = this.castCollectionId(docId)
463✔
384

385
  const stateArr = state.split(',')
463✔
386
  const doc = await this.crudService.findById(crudContext, _id, filter, projection, stateArr)
463✔
387
  if (!doc) {
463✔
388
    return reply.notFound()
104✔
389
  }
390

391
  this.castItem(doc)
359✔
392
  return doc
359✔
393
}
394

395
async function handleInsertOne(request, reply) {
396
  if (this.customMetrics) {
98✔
397
    this.customMetrics.collectionInvocation.inc({
14✔
398
      collection_name: this.modelName,
399
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
400
    })
401
  }
402

403
  const { body: doc, crudContext } = request
98✔
404

405
  this.queryParser.parseAndCastBody(doc)
98✔
406

407
  try {
98✔
408
    const insertedDoc = await this.crudService.insertOne(crudContext, doc)
98✔
409
    return { _id: insertedDoc._id }
94✔
410
  } catch (error) {
411
    if (error.code === UNIQUE_INDEX_MONGO_ERROR_CODE) {
4!
412
      request.log.error('unique index violation')
4✔
413
      return reply.getHttpError(UNIQUE_INDEX_ERROR_STATUS_CODE, error.message)
4✔
414
    }
415
    throw error
×
416
  }
417
}
418

419
// eslint-disable-next-line no-unused-vars
420
async function handleValidate(request, reply) {
421
  return { result: 'ok' }
4✔
422
}
423

424
async function handleDeleteId(request, reply) {
425
  if (this.customMetrics) {
70✔
426
    this.customMetrics.collectionInvocation.inc({
2✔
427
      collection_name: this.modelName,
428
      type: PROMETHEUS_OP_TYPE.DELETE,
429
    })
430
  }
431

432
  const { query, headers, params, crudContext } = request
70✔
433

434
  const docId = params.id
70✔
435
  const _id = this.castCollectionId(docId)
70✔
436

437
  const {
438
    [QUERY]: clientQueryString,
439
    [STATE]: state,
440
    ...otherParams
441
  } = query
70✔
442
  const { acl_rows } = headers
70✔
443

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

446
  const stateArr = state.split(',')
70✔
447
  const doc = await this.crudService.deleteById(crudContext, _id, filter, stateArr)
70✔
448

449
  if (!doc) {
70✔
450
    return reply.notFound()
24✔
451
  }
452

453
  // the document should not be returned:
454
  // we don't know which projection the user is able to see
455
  reply.code(204)
46✔
456
}
457

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

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

468
  const {
469
    [QUERY]: clientQueryString,
470
    [STATE]: state,
471
    ...otherParams
472
  } = query
54✔
473
  const { acl_rows } = headers
54✔
474

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

477
  const stateArr = state.split(',')
54✔
478
  return this.crudService.deleteAll(crudContext, filter, stateArr)
54✔
479
}
480

481
async function handleCount(request) {
482
  if (this.customMetrics) {
56✔
483
    this.customMetrics.collectionInvocation.inc({
4✔
484
      collection_name: this.modelName,
485
      type: PROMETHEUS_OP_TYPE.FETCH,
486
    })
487
  }
488

489
  const { query, headers, crudContext } = request
56✔
490
  const {
491
    [QUERY]: clientQueryString,
492
    [STATE]: state,
493
    ...otherParams
494
  } = query
56✔
495
  const { acl_rows } = headers
56✔
496

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

499
  const stateArr = state.split(',')
56✔
500
  return this.crudService.count(crudContext, mongoQuery, stateArr)
56✔
501
}
502

503
async function handlePatchId(request, reply) {
504
  if (this.customMetrics) {
294✔
505
    this.customMetrics.collectionInvocation.inc({
2✔
506
      collection_name: this.modelName,
507
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
508
    })
509
  }
510

511
  const { query, headers, params, crudContext, log } = request
294✔
512
  const {
513
    [QUERY]: clientQueryString,
514
    [STATE]: state,
515
    ...otherParams
516
  } = query
294✔
517
  const {
518
    acl_rows,
519
    acl_write_columns: aclWriteColumns,
520
    acl_read_columns: aclColumns = '',
290✔
521
  } = headers
294✔
522

523
  const commands = request.body
294✔
524

525
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
294✔
526

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

529
  this.queryParser.parseAndCastCommands(commands, editableFields)
294✔
530
  const projection = resolveProjection('', aclColumns, this.allFieldNames, '', log)
294✔
531

532
  const docId = params.id
294✔
533
  const _id = this.castCollectionId(docId)
294✔
534

535
  const stateArr = state.split(',')
294✔
536
  const doc = await this.crudService.patchById(crudContext, _id, commands, mongoQuery, projection, stateArr)
294✔
537

538
  if (!doc) {
290✔
539
    return reply.notFound()
68✔
540
  }
541

542
  this.castItem(doc)
222✔
543
  return doc
222✔
544
}
545

546
async function handlePatchMany(request) {
547
  if (this.customMetrics) {
112!
548
    this.customMetrics.collectionInvocation.inc({
×
549
      collection_name: this.modelName,
550
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
551
    })
552
  }
553

554
  const { query, headers, crudContext } = request
112✔
555
  const {
556
    [QUERY]: clientQueryString,
557
    [STATE]: state,
558
    ...otherParams
559
  } = query
112✔
560
  const {
561
    acl_rows,
562
    acl_write_columns: aclWriteColumns,
563
  } = headers
112✔
564

565
  const commands = request.body
112✔
566
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
112✔
567
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
112✔
568
  this.queryParser.parseAndCastCommands(commands, editableFields)
112✔
569

570
  const stateArr = state.split(',')
112✔
571
  const nModified = await this.crudService.patchMany(crudContext, commands, mongoQuery, stateArr)
112✔
572

573
  return nModified
112✔
574
}
575

576
async function handleUpsertOne(request) {
577
  if (this.customMetrics) {
88!
578
    this.customMetrics.collectionInvocation.inc({
×
579
      collection_name: this.modelName,
580
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
581
    })
582
  }
583

584
  const { query, headers, crudContext, log } = request
88✔
585
  const {
586
    [QUERY]: clientQueryString,
587
    [STATE]: state,
588
    ...otherParams
589
  } = query
88✔
590
  const {
591
    acl_rows,
592
    acl_write_columns: aclWriteColumns,
593
    acl_read_columns: aclColumns = '',
72✔
594
  } = headers
88✔
595

596
  const commands = request.body
88✔
597

598
  const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
88✔
599

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

602
  this.queryParser.parseAndCastCommands(commands, editableFields)
88✔
603
  const projection = resolveProjection('', aclColumns, this.allFieldNames, '', log)
88✔
604

605
  const stateArr = state.split(',')
88✔
606
  const doc = await this.crudService.upsertOne(crudContext, commands, mongoQuery, projection, stateArr)
88✔
607

608
  this.castItem(doc)
88✔
609
  return doc
88✔
610
}
611

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

620
  const { body: filterUpdateCommands, crudContext, headers } = request
104✔
621

622
  const {
623
    acl_rows,
624
    acl_write_columns: aclWriteColumns,
625
  } = headers
104✔
626

627
  const parsedAndCastedCommands = new Array(filterUpdateCommands.length)
104✔
628
  for (let i = 0; i < filterUpdateCommands.length; i++) {
104✔
629
    const { filter, update } = filterUpdateCommands[i]
80,136✔
630
    const {
631
      _id,
632
      [QUERY]: clientQueryString,
633
      [STATE]: state,
634
      ...otherParams
635
    } = filter
80,136✔
636

637
    const commands = update
80,136✔
638

639
    const editableFields = getEditableFields(aclWriteColumns, this.allFieldNames)
80,136✔
640

641
    const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
80,136✔
642

643
    this.queryParser.parseAndCastCommands(commands, editableFields)
80,136✔
644

645
    parsedAndCastedCommands[i] = {
80,136✔
646
      commands,
647
      state: state.split(','),
648
      query: mongoQuery,
649
    }
650
    if (_id) {
80,136✔
651
      parsedAndCastedCommands[i].query._id = this.castCollectionId(_id)
80,124✔
652
    }
653
  }
654

655
  const nModified = await this.crudService.patchBulk(crudContext, parsedAndCastedCommands)
104✔
656
  return nModified
104✔
657
}
658

659
async function handleInsertMany(request, reply) {
660
  if (this.customMetrics) {
80!
661
    this.customMetrics.collectionInvocation.inc({
×
662
      collection_name: this.modelName,
663
      type: PROMETHEUS_OP_TYPE.INSERT_OR_UPDATE,
664
    })
665
  }
666

667
  const { body: docs, crudContext } = request
80✔
668

669
  docs.forEach(this.queryParser.parseAndCastBody)
80✔
670

671
  try {
80✔
672
    const insertedDocs = await this.crudService.insertMany(crudContext, docs)
80✔
673
    return insertedDocs.map(mapToObjectWithOnlyId)
76✔
674
  } catch (error) {
675
    if (error.code === UNIQUE_INDEX_MONGO_ERROR_CODE) {
4!
676
      request.log.error('unique index violation')
4✔
677
      return reply.getHttpError(UNIQUE_INDEX_ERROR_STATUS_CODE, error.message)
4✔
678
    }
679
    throw error
×
680
  }
681
}
682

683
async function handleChangeStateById(request, reply) {
684
  if (this.customMetrics) {
44!
685
    this.customMetrics.collectionInvocation.inc({
×
686
      collection_name: this.modelName,
687
      type: PROMETHEUS_OP_TYPE.CHANGE_STATE,
688
    })
689
  }
690

691
  const { body, crudContext, headers, query } = request
44✔
692
  const {
693
    [QUERY]: clientQueryString,
694
    ...otherParams
695
  } = query
44✔
696

697
  const { acl_rows } = headers
44✔
698
  const mongoQuery = resolveMongoQuery(this.queryParser, clientQueryString, acl_rows, otherParams, false)
44✔
699

700
  const docId = request.params.id
44✔
701
  const _id = this.castCollectionId(docId)
44✔
702

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

705
  if (!doc) {
44✔
706
    return reply.notFound()
16✔
707
  }
708

709
  reply.code(204)
28✔
710
}
711

712
async function handleChangeStateMany(request) {
713
  if (this.customMetrics) {
64!
714
    this.customMetrics.collectionInvocation.inc({
×
715
      collection_name: this.modelName,
716
      type: PROMETHEUS_OP_TYPE.CHANGE_STATE,
717
    })
718
  }
719

720
  const { body: filterUpdateCommands, crudContext, headers } = request
64✔
721

722
  const {
723
    acl_rows,
724
  } = headers
64✔
725

726
  const parsedAndCastedCommands = new Array(filterUpdateCommands.length)
64✔
727
  for (let i = 0; i < filterUpdateCommands.length; i++) {
64✔
728
    const {
729
      filter,
730
      stateTo,
731
    } = filterUpdateCommands[i]
80✔
732

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

735
    parsedAndCastedCommands[i] = {
80✔
736
      query: mongoQuery,
737
      stateTo,
738
    }
739
  }
740

741
  return this.crudService.changeStateMany(crudContext, parsedAndCastedCommands)
64✔
742
}
743

744
async function injectContextInRequest(request) {
745
  const userIdHeader = request.headers[this.userIdHeaderKey]
2,266✔
746
  const isUserHeaderInvalid = INVALID_USERID.includes(userIdHeader)
2,266✔
747

748
  let userId = 'public'
2,266✔
749

750
  if (userIdHeader && !isUserHeaderInvalid) {
2,266✔
751
    userId = userIdHeader
796✔
752
  }
753

754
  request.crudContext = {
2,266✔
755
    log: request.log,
756
    userId,
757
    now: new Date(),
758
  }
759
}
760

761
async function parseEncodedJsonQueryParams(logger, request) {
762
  if (request.headers.json_query_params_encoding) {
2,266!
763
    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.')
×
764
  }
765

766
  // TODO remove request.headers.json_query_params_encoding fallback in v7.0.0
767
  const jsonQueryParamsEncoding = request.headers['json-query-params-encoding'] || request.headers.json_query_params_encoding
2,266✔
768
  switch (jsonQueryParamsEncoding) {
2,266✔
769
  case 'base64': {
770
    const queryJsonFields = [QUERY, RAW_PROJECTION]
12✔
771
    for (const field of queryJsonFields) {
12✔
772
      if (request.query[field]) {
24✔
773
        request.query[field] = Buffer.from(request.query[field], jsonQueryParamsEncoding).toString()
12✔
774
      }
775
    }
776
    break
12✔
777
  }
778
  default: break
2,254✔
779
  }
780
}
781

782
async function notFoundHandler(request, reply) {
783
  reply
236✔
784
    .code(404)
785
    .send({
786
      error: 'not found',
787
    })
788
}
789

790
async function customErrorHandler(error, request, reply) {
791
  if (error.statusCode === 404) {
598✔
792
    return notFoundHandler(request, reply)
212✔
793
  }
794

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

800
  throw error
242✔
801
}
802

803
function streamSerializer() {
804
  return JSONStream.stringify()
371✔
805
}
806

807
function fastNdjsonSerializer(stringify) {
808
  function ndjsonTransform(obj, encoding, callback) {
809
    this.push(`${stringify(obj)}\n`)
2,240✔
810
    callback()
2,240✔
811
  }
812
  return function ndjsonSerializer() {
4,046✔
813
    return through2.obj(ndjsonTransform)
228✔
814
  }
815
}
816

817
function resolveMongoQuery(
818
  queryParser,
819
  clientQueryString,
820
  rawAclRows,
821
  otherParams,
822
  textQuery
823
) {
824
  const mongoQuery = {
81,996✔
825
    $and: [],
826
  }
827
  if (clientQueryString) {
81,996✔
828
    const clientQuery = JSON.parse(clientQueryString)
530✔
829
    mongoQuery.$and.push(clientQuery)
530✔
830
  }
831
  if (otherParams) {
81,996!
832
    for (const key of Object.keys(otherParams)) {
81,996✔
833
      const value = otherParams[key]
606✔
834
      mongoQuery.$and.push({ [key]: value })
606✔
835
    }
836
  }
837

838
  if (rawAclRows) {
81,996✔
839
    const aclRows = JSON.parse(rawAclRows)
298✔
840
    if (rawAclRows[0] === '[') {
298✔
841
      mongoQuery.$and.push({ $and: aclRows })
282✔
842
    } else {
843
      mongoQuery.$and.push(aclRows)
16✔
844
    }
845
  }
846

847
  if (textQuery) {
81,996✔
848
    queryParser.parseAndCastTextSearchQuery(mongoQuery)
48✔
849
  } else {
850
    queryParser.parseAndCast(mongoQuery)
81,948✔
851
  }
852

853
  if (mongoQuery.$and && !mongoQuery.$and.length) {
81,996✔
854
    return { }
80,974✔
855
  }
856

857
  return mongoQuery
1,022✔
858
}
859

860
function resolveProjection(clientProjectionString, aclColumns, allFieldNames, rawProjection, log) {
861
  log.debug('Resolving projections')
1,556✔
862
  const acls = splitACLs(aclColumns)
1,556✔
863

864
  if (clientProjectionString && rawProjection) {
1,556✔
865
    log.error('Use of both _p and _rawp is not permitted')
24✔
866
    throw new BadRequestError(
24✔
867
      'Use of both _rawp and _p parameter is not allowed')
868
  }
869

870
  if (!clientProjectionString && !rawProjection) {
1,532✔
871
    return removeAclColumns(allFieldNames, acls)
1,167✔
872
  } else if (rawProjection) {
365✔
873
    return resolveRawProjectionString(rawProjection, acls, allFieldNames, log)
161✔
874
  } else if (clientProjectionString) {
204!
875
    return resolveClientProjectionString(clientProjectionString, acls)
204✔
876
  }
877
}
878

879
function resolveClientProjectionString(clientProjectionString, _acls) {
880
  const clientProjection = getClientProjection(clientProjectionString)
204✔
881
  return removeAclColumns(clientProjection, _acls)
204✔
882
}
883

884
function resolveRawProjectionString(rawProjection, _acls, allFieldNames, log) {
885
  try {
161✔
886
    checkAllowedOperators(
161✔
887
      rawProjection,
888
      rawProjectionDictionary,
889
      _acls.length > 0 ? _acls : allFieldNames, log)
161✔
890

891
    const rawProjectionObject = resolveRawProjection(rawProjection)
81✔
892
    const projection = removeAclColumnsFromRawProjection(rawProjectionObject, _acls)
81✔
893

894
    return !isEmpty(projection) ? [projection] : []
73✔
895
  } catch (errorMessage) {
896
    log.error(errorMessage.message)
88✔
897
    throw new BadRequestError(errorMessage.message)
88✔
898
  }
899
}
900

901
function splitACLs(acls) {
902
  if (acls) { return acls.split(',') }
1,556✔
903
  return []
1,417✔
904
}
905

906
function removeAclColumns(fieldsInProjection, aclColumns) {
907
  if (aclColumns.length > 0) {
1,444✔
908
    return fieldsInProjection.filter(field => {
131✔
909
      return aclColumns.indexOf(field) > -1
837✔
910
    })
911
  }
912

913
  return fieldsInProjection
1,313✔
914
}
915

916
function removeAclColumnsFromRawProjection(rawProjectionObject, aclColumns) {
917
  const isRawProjectionOverridingACLs = checkIfRawProjectionOverridesAcls(rawProjectionObject, aclColumns)
81✔
918
  if (isRawProjectionOverridingACLs) {
81✔
919
    throw Error('_rawp exclusive projection is overriding at least one acl_read_column value')
8✔
920
  }
921

922
  const rawProjectionFields = Object.keys(rawProjectionObject)
73✔
923
  const filteredFields = removeAclColumns(rawProjectionFields, aclColumns)
73✔
924

925
  return filteredFields.reduce((acc, current) => {
73✔
926
    if (rawProjectionObject[current] === 0 || rawProjectionObject[current]) {
125!
927
      acc[current] = rawProjectionObject[current]
125✔
928
    }
929
    return acc
125✔
930
  }, {})
931
}
932

933
function getClientProjection(clientProjectionString) {
934
  if (clientProjectionString) {
204!
935
    return clientProjectionString.split(',')
204✔
936
  }
937
  return []
×
938
}
939

940
function resolveRawProjection(clientRawProjectionString) {
941
  if (clientRawProjectionString) {
81!
942
    return JSON.parse(clientRawProjectionString)
81✔
943
  }
944
  return {}
×
945
}
946

947
function checkAllowedOperators(rawProjection, projectionDictionary, additionalFields, log) {
948
  if (!rawProjection) {
161!
949
    log.debug('No raw projection found: checkAllowedOperators returns true')
×
950
    return true
×
951
  }
952

953
  const { allowedOperators, notAllowedOperators } = projectionDictionary
161✔
954
  const allowedFields = [...allowedOperators]
161✔
955

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

958
  log.debug({ allowedOperators: allowedFields }, 'Allowed operators for projection')
161✔
959
  log.debug({ notAllowedOperators }, 'Not allowed operators for projection')
161✔
960

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

965
  if (!matches) {
161✔
966
    log.debug('No operators found in raw projection: checkAllowedOperators returns true')
63✔
967
    return true
63✔
968
  }
969

970
  return !matches.some(match => {
98✔
971
    if (match.startsWith('$$')) {
172✔
972
      log.debug({ match }, 'Found $$ match in raw projection')
76✔
973
      if (notAllowedOperators.includes(match)) {
76✔
974
        throw Error(`Operator ${match} is not allowed in raw projection`)
64✔
975
      }
976

977
      return notAllowedOperators.includes(match)
12✔
978
    }
979

980
    if (!allowedFields.includes(match)) {
96✔
981
      throw Error(`Operator ${match} is not allowed in raw projection`)
16✔
982
    }
983

984
    return !allowedFields.includes(match)
80✔
985
  })
986
}
987

988
function checkIfRawProjectionOverridesAcls(rawProjection, acls) {
989
  return Object.keys(rawProjection).some(field =>
81✔
990
    acls.includes(field) && rawProjection[field] === 0
199✔
991
  )
992
}
993

994
function mapToObjectWithOnlyId(doc) {
995
  return { _id: doc._id }
200,148✔
996
}
997

998
const internalFields = [
100✔
999
  UPDATERID,
1000
  UPDATEDAT,
1001
  CREATORID,
1002
  CREATEDAT,
1003
  __STATE__,
1004
]
1005
function getEditableFields(aclWriteColumns, allFieldNames) {
1006
  const editableFields = aclWriteColumns ? aclWriteColumns.split(',') : allFieldNames
80,630!
1007
  return editableFields.filter(ef => !internalFields.includes(ef))
1,693,044✔
1008
}
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