adapt-authoring-api 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,668 @@
1
+ import _ from 'lodash'
2
+ import { AbstractModule, Hook } from 'adapt-authoring-core'
3
+ import ApiUtils from './AbstractApiUtils.js'
4
+ import DataCache from './DataCache.js'
5
+ /**
6
+ * Abstract module for creating APIs
7
+ * @memberof api
8
+ * @extends {AbstractModule}
9
+ */
10
+ class AbstractApiModule extends AbstractModule {
11
+ get DEFAULT_ROUTES () {
12
+ const readPerms = [`read:${this.permissionsScope || this.root}`]
13
+ const writePerms = [`write:${this.permissionsScope || this.root}`]
14
+ const handler = this.requestHandler()
15
+ return [
16
+ {
17
+ route: '/',
18
+ handlers: { post: handler, get: this.queryHandler() },
19
+ permissions: { post: writePerms, get: readPerms }
20
+ },
21
+ {
22
+ route: '/schema',
23
+ handlers: { get: this.serveSchema.bind(this) },
24
+ permissions: { get: ['read:schema'] }
25
+ },
26
+ {
27
+ route: '/:_id',
28
+ handlers: { put: handler, get: handler, patch: handler, delete: handler },
29
+ permissions: { put: writePerms, get: readPerms, patch: writePerms, delete: writePerms }
30
+ },
31
+ {
32
+ route: '/query',
33
+ validate: false,
34
+ modifying: false,
35
+ handlers: { post: this.queryHandler() },
36
+ permissions: { post: readPerms }
37
+ }
38
+ ]
39
+ }
40
+ /**
41
+ * Returns the 'OK' status code to match the HTTP method
42
+ * @param {String} httpMethod
43
+ * @return {Number} HTTP status code
44
+ */
45
+ mapStatusCode (httpMethod) {
46
+ const map = {
47
+ post: 201,
48
+ get: 200,
49
+ put: 200,
50
+ patch: 200,
51
+ delete: 204
52
+ }
53
+ return map[httpMethod]
54
+ }
55
+
56
+ /** @override */
57
+ async init () {
58
+ /**
59
+ * Signifies that the module instance is an API module. Can be used by other modules for quick verification checks.
60
+ * @type {Boolean}
61
+ */
62
+ this.isApiModule = true
63
+ /**
64
+ * Data cache which can be used to reduce DB calls
65
+ */
66
+ this.cache = new DataCache({
67
+ enable: this.getConfig('enableCache'),
68
+ lifespan: this.getConfig('cacheLifespan')
69
+ })
70
+ /**
71
+ * Hook invoked when a new API request is handled
72
+ * @type {Hook}
73
+ */
74
+ this.requestHook = new Hook({ mutable: true })
75
+ /**
76
+ * Hook invoked before data is inserted into the database
77
+ * @type {Hook}
78
+ */
79
+ this.preInsertHook = new Hook({ mutable: true })
80
+ /**
81
+ * Hook invoked after data is inserted into the database
82
+ * @type {Hook}
83
+ */
84
+ this.postInsertHook = new Hook()
85
+ /**
86
+ * Hook invoked before data is updated in the database
87
+ * @type {Hook}
88
+ */
89
+ this.preUpdateHook = new Hook({ mutable: true })
90
+ /**
91
+ * Hook invoked after data is updated in the database
92
+ * @type {Hook}
93
+ */
94
+ this.postUpdateHook = new Hook()
95
+ /**
96
+ * Hook invoked before data is deleted
97
+ * @type {Hook}
98
+ */
99
+ this.preDeleteHook = new Hook()
100
+ /**
101
+ * Hook invoked after data is deleted
102
+ * @type {Hook}
103
+ */
104
+ this.postDeleteHook = new Hook()
105
+ /**
106
+ * Hook invoked by DB wrapper functions to check access to individual data items
107
+ * @type {Hook}
108
+ */
109
+ this.accessCheckHook = new Hook()
110
+
111
+ await this.setValues()
112
+ this.validateValues()
113
+ await this.addRoutes()
114
+ }
115
+
116
+ /**
117
+ * Sets values used to initialise the API
118
+ * @return {Promise}
119
+ */
120
+ async setValues () {
121
+ /**
122
+ * Name of the API module
123
+ * @type {String}
124
+ */
125
+ this.root = undefined
126
+ /**
127
+ * The Router instance used for HTTP requests
128
+ * @type {Router}
129
+ */
130
+ this.router = undefined
131
+ /**
132
+ * Routes to be added to the API router
133
+ * @type {Array<ApiRoute>}
134
+ */
135
+ this.routes = undefined
136
+ /**
137
+ * The scope to be used (see AbstractApiModule#useDefaultRouteConfig)
138
+ * @type {String}
139
+ */
140
+ this.permissionsScope = undefined
141
+ /**
142
+ * Default DB collection to store data to (can be overridden by individual handlers)
143
+ * @type {String}
144
+ */
145
+ this.collectionName = undefined
146
+ /**
147
+ * Default schema to use for validation (can be overridden by individual handlers)
148
+ * @type {String}
149
+ */
150
+ this.schemaName = undefined
151
+ }
152
+
153
+ /**
154
+ * Takes an input options param and populates it with defaults
155
+ * @param {Object} options
156
+ */
157
+ setDefaultOptions (options = {}) {
158
+ _.defaults(options, {
159
+ schemaName: this.schemaName,
160
+ collectionName: this.collectionName,
161
+ validate: true,
162
+ invokePreHook: true,
163
+ invokePostHook: true
164
+ })
165
+ }
166
+
167
+ /**
168
+ * Uses default configuration for API routes
169
+ * @example
170
+ * POST /
171
+ * GET /:_id?
172
+ * PUT/DELETE /:_id
173
+ */
174
+ useDefaultRouteConfig () {
175
+ if (!this.root) {
176
+ return this.log('error', 'Must set API root before calling useDefaultConfig function')
177
+ }
178
+ /** @ignore */ this.routes = this.DEFAULT_ROUTES
179
+ ApiUtils.generateApiMetadata(this)
180
+ }
181
+
182
+ /**
183
+ * Checks required values have been set
184
+ */
185
+ validateValues () {
186
+ if (!this.root && !this.router) {
187
+ throw this.app.errors.NO_ROOT_OR_ROUTER_DEF
188
+ }
189
+ if (!this.routes) {
190
+ throw this.app.errors.NO_ROUTES_DEF
191
+ }
192
+ if (!this.collectionName) {
193
+ throw this.app.errors.NO_COLL_NAME
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Adds any defined routes
199
+ * @return {Promise}
200
+ */
201
+ async addRoutes () {
202
+ if (!this.router) {
203
+ const server = await this.app.waitForModule('server')
204
+ /** @ignore */ this.router = server.api.createChildRouter(this.root)
205
+ }
206
+ const uniqueRoutes = {}
207
+ this.routes.forEach(r => {
208
+ if (uniqueRoutes[r.route]) {
209
+ return this.log('warn', `duplicate route defined for path '${r.route}', first definition will be used`)
210
+ }
211
+ uniqueRoutes[r.route] = r
212
+ })
213
+ this.router.addHandlerMiddleware(
214
+ this.processRequestMiddleware.bind(this),
215
+ this.sanitiseRequestDataMiddleware.bind(this)
216
+ )
217
+ const auth = await this.app.waitForModule('auth')
218
+ Object.values(uniqueRoutes).forEach(r => this.addRoute(r, auth))
219
+ }
220
+
221
+ /**
222
+ * Adds a single route definition
223
+ * @param {Route} config The route config
224
+ * @param {AuthModule} auth Reference to the AuthModule instance to save await-ing
225
+ */
226
+ addRoute (config, auth) {
227
+ Object.entries(config.handlers).forEach(([method, handler]) => {
228
+ config.handlers[method] = Array.isArray(handler) ? [...handler] : [handler]
229
+ const perms = config.permissions && config.permissions[method]
230
+ if (perms) { // remove any trailing slashes first
231
+ const route = config.route.endsWith('/') ? config.route.slice(0, -1) : config.route
232
+ auth.secureRoute(this.router.path + route, method, perms)
233
+ }
234
+ }, {})
235
+ this.router.addRoute(config)
236
+ }
237
+
238
+ /**
239
+ * Derives the schema name from passed apiData
240
+ * @param {Object} data Request data
241
+ * @return {String} The schema name
242
+ */
243
+ async getSchemaName (data) {
244
+ return this.schemaName
245
+ }
246
+
247
+ /**
248
+ * Retrieves a schema by name
249
+ * @param {String} schemaName
250
+ * @param {Object} data Can be used when determining schema type
251
+ * @return {Object}
252
+ */
253
+ async getSchema (schemaName, data) {
254
+ return (await this.app.waitForModule('jsonschema')).getSchema(schemaName)
255
+ }
256
+
257
+ /**
258
+ * Express request handler for serving the schema
259
+ * @param {external:ExpressRequest} req
260
+ * @param {external:ExpressResponse} res
261
+ * @param {Function} next
262
+ * @return {function}
263
+ */
264
+ async serveSchema (req, res, next) {
265
+ try {
266
+ const schema = await this.getSchema(req.apiData.schemaName, { ...req.apiData.query, ...req.apiData.data })
267
+ if (!schema) {
268
+ return next(this.app.errors.NO_SCHEMA_DEF)
269
+ }
270
+ res.type('application/schema+json').json(schema.built)
271
+ } catch (e) {
272
+ return next(e)
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Validates data
278
+ * @param {String} schemaName Name of the schema to validate against
279
+ * @param {Object} data Data to validate
280
+ * @param {Object} options
281
+ */
282
+ async validate (schemaName, data, options) {
283
+ if (options.validate === false) {
284
+ return data
285
+ }
286
+ const schema = await this.getSchema(schemaName, data)
287
+ return schema.validate(data, options)
288
+ }
289
+
290
+ /**
291
+ * Recursive sanitiser, see sanitiseItem
292
+ * @param {string} schemaName Name of schema to sanitise against
293
+ * @param {object} data Data to sanitise
294
+ * @param {object} options see sanitiseItem
295
+ * @return {Promise} Resolves with the sanitised data
296
+ */
297
+ async sanitise (schemaName, data, options) {
298
+ const isArray = Array.isArray(data)
299
+ const sanitised = await Promise.all((isArray ? data : [data]).map(async d => {
300
+ const schema = await this.getSchema(schemaName, d)
301
+ return schema.sanitise(d, options)
302
+ }))
303
+ return isArray ? sanitised : sanitised[0]
304
+ }
305
+
306
+ /**
307
+ * Express middleware which correctly formats incoming request data and stores as req.apiData to be used by later handlers. See ApiRequestData typedef for full details.
308
+ * @param {external:ExpressRequest} req
309
+ * @param {external:ExpressResponse} res
310
+ * @param {Function} next
311
+ * @return {Function} Middleware function
312
+ */
313
+ async processRequestMiddleware (req, res, next) {
314
+ const config = req.routeConfig
315
+ const collectionName = config.collectionName || this.collectionName
316
+ const modifiers = config?.modifiers ?? ['post', 'put', 'patch', 'delete']
317
+ const modifying = config.modifying ?? modifiers.includes(req.method.toLowerCase())
318
+ const data = _.cloneDeep(req.body)
319
+ const query = Object.assign(_.cloneDeep(req.query), _.cloneDeep(req.params))
320
+ const schemaName = await this.getSchemaName({ ...query, ...data })
321
+ req.apiData = { collectionName, config, data, modifying, query, schemaName }
322
+ next()
323
+ }
324
+
325
+ /**
326
+ * Sanitises incoming request data
327
+ * @param {external:ExpressRequest} req
328
+ * @param {external:ExpressResponse} res
329
+ * @param {Function} next
330
+ * @return {Function} Middleware function
331
+ */
332
+ async sanitiseRequestDataMiddleware (req, res, next) {
333
+ try {
334
+ if (req.apiData.modifying) {
335
+ const data = { _id: req.apiData.query?._id, ...req.apiData.data }
336
+ req.apiData.data = await this.sanitise(req.apiData.schemaName, data, { isReadOnly: true })
337
+ }
338
+ } catch (e) {
339
+ return next(e)
340
+ }
341
+ next()
342
+ }
343
+
344
+ /**
345
+ * Middleware to handle a generic API request. Supports POST, GET, PUT and DELETE of items in the database.
346
+ * @return {Function} Express middleware function
347
+ */
348
+ requestHandler () {
349
+ const requestHandler = async (req, res, next) => {
350
+ const method = req.method.toLowerCase()
351
+ const func = this[ApiUtils.httpMethodToDBFunction(method)]
352
+ if (!func) {
353
+ return next(this.app.errors.HTTP_METHOD_NOT_SUPPORTED.setData({ method }))
354
+ }
355
+ let data
356
+ try {
357
+ await this.requestHook.invoke(req)
358
+ const preCheck = !req.auth.isSuper && method !== 'get' && method !== 'post'
359
+ const postCheck = !req.auth.isSuper && method === 'get'
360
+ if (preCheck) {
361
+ await this.checkAccess(req, req.apiData.query)
362
+ }
363
+ data = await func.apply(this, ApiUtils.argsFromReq(req))
364
+ if (postCheck) {
365
+ data = await this.checkAccess(req, data)
366
+ }
367
+ data = await this.sanitise(req.apiData.schemaName, data, { isInternal: true, strict: false })
368
+ } catch (e) {
369
+ return next(e)
370
+ }
371
+ if (Array.isArray(data) && req.params._id) { // special case for when _id param is present
372
+ if (!data.length) {
373
+ return next(this.app.errors.NOT_FOUND.setData({ id: req.params.id, type: req.apiData.schemaName }))
374
+ }
375
+ data = data[0]
376
+ }
377
+ if (method !== 'get') {
378
+ const resource = Array.isArray(data) ? req.apiData.query : data._id.toString()
379
+ this.log('debug', `API_${func.name.toUpperCase()}`, resource, 'by', req.auth.user._id.toString())
380
+ }
381
+ res.status(this.mapStatusCode(method)).json(data)
382
+ }
383
+ return requestHandler
384
+ }
385
+
386
+ /**
387
+ * Express request handler for advanced API queries. Supports collation/limit/page/skip/sort and pagination. For incoming query data to be correctly parsed, it must be sent as body data using a POST request.
388
+ * @return {function}
389
+ */
390
+ queryHandler () {
391
+ const queryHandler = async (req, res, next) => {
392
+ try {
393
+ const opts = {
394
+ schemaName: req.apiData.schemaName,
395
+ collectionName: req.apiData.collectionName
396
+ }
397
+ const mongoOpts = {}
398
+ // find and remove mongo options from the query
399
+ Object.entries(req.apiData.query).forEach(([key, val]) => {
400
+ if (['collation', 'limit', 'page', 'skip', 'sort'].includes(key)) {
401
+ try {
402
+ mongoOpts[key] = JSON.parse(req.apiData.query[key])
403
+ } catch (e) {
404
+ this.log('warn', `failed to parse query ${key} param '${mongoOpts[key]}', ${e}`)
405
+ }
406
+ delete req.apiData.query[key]
407
+ } else {
408
+ // otherwise assume we have a query field or option and store for later processing
409
+ opts[key] = val
410
+ }
411
+ })
412
+ req.apiData.query = await this.parseQuery(req.apiData.schemaName, req.body, mongoOpts)
413
+ // remove any valid query keys from the options
414
+ Object.keys(req.apiData.query).forEach(key => delete opts[key])
415
+
416
+ await this.setUpPagination(req, res, mongoOpts)
417
+
418
+ let results = await this.find(req.apiData.query, opts, mongoOpts)
419
+
420
+ results = await this.checkAccess(req, results)
421
+ results = await this.sanitise(req.apiData.schemaName, results, { isInternal: true, strict: false })
422
+
423
+ res.status(this.mapStatusCode('get')).json(results)
424
+ } catch (e) {
425
+ return next(e)
426
+ }
427
+ }
428
+ return queryHandler
429
+ }
430
+
431
+ /**
432
+ * Parses an incoming query for use in the DB module
433
+ * @param {String} schemaName The schema name for the data being queried
434
+ * @param {Object} query The query data
435
+ * @param {Object} mongoOptions Options to be passed to the MongoDB function
436
+ * @returns {Object} The parsed query
437
+ */
438
+ async parseQuery (schemaName, query = {}, mongoOptions) {
439
+ let q = Object.assign({}, query)
440
+ if (schemaName) {
441
+ try {
442
+ const opts = { ignoreRequired: true, useDefaults: false, ...mongoOptions }
443
+ if (q.$or) {
444
+ q.$or = await Promise.all(q.$or.map(async expr => {
445
+ try {
446
+ return await this.validate(schemaName, expr, opts)
447
+ } catch (e) { // just return the original expression on error
448
+ return expr
449
+ }
450
+ }))
451
+ } else {
452
+ q = await this.validate(schemaName, q, opts)
453
+ }
454
+ } catch (e) {}
455
+ }
456
+ return q
457
+ }
458
+
459
+ /**
460
+ * Validates and sets the relevant pagination options (limit, skip) and HTTP headers (X-Adapt-Page, X-Adapt-PageTotal, Link).
461
+ * @param {external:ExpressRequest} req
462
+ * @param {external:ExpressResponse} res
463
+ * @param {Object} mongoOpts The MongoDB options
464
+ */
465
+ async setUpPagination (req, res, mongoOpts) {
466
+ const maxPageSize = this.app.config.get('adapt-authoring-api.maxPageSize')
467
+ let pageSize = mongoOpts.limit ?? this.app.config.get('adapt-authoring-api.defaultPageSize')
468
+
469
+ if (pageSize > maxPageSize) pageSize = maxPageSize
470
+
471
+ const mongodb = await this.app.waitForModule('mongodb')
472
+ const docCount = await mongodb.getCollection(req.apiData.collectionName).countDocuments(req.apiData.query)
473
+ const pageTotal = Math.ceil(docCount / pageSize) || 1
474
+ let page = parseInt(mongoOpts.page)
475
+
476
+ if (isNaN(page) || page < 1) page = 1 // normalise invalid values
477
+ if (page > pageTotal) page = pageTotal
478
+
479
+ res.set('X-Adapt-Page', page)
480
+ res.set('X-Adapt-PageSize', pageSize)
481
+ res.set('X-Adapt-PageTotal', pageTotal)
482
+
483
+ if (pageTotal > 1) {
484
+ // absolute URL with paging params removed
485
+ const baseUrl = `${req.originalUrl.split('?')[0]}`
486
+ const query = Object.entries(req.query)
487
+ .filter(([k]) => k !== 'page' && k !== 'limit')
488
+ .map(([k, v]) => `${k}=${v}`)
489
+ .concat([`limit=${pageSize}`])
490
+ .join('&')
491
+ const prevPage = page - 1
492
+ const nextPage = page + 1
493
+ const makeLink = (page, rel) => `<${baseUrl}?${query}&page=${page}>; rel="${rel}"`
494
+ const links = [
495
+ page > 1 && makeLink(1, 'first'),
496
+ prevPage > 0 && makeLink(prevPage, 'prev'),
497
+ nextPage <= pageTotal && makeLink(nextPage, 'next'),
498
+ page < pageTotal && makeLink(pageTotal, 'last')
499
+ ].filter(Boolean).join(', ')
500
+
501
+ res.set('Link', links)
502
+ }
503
+ // add pagination attributes to mongodb options
504
+ Object.assign(mongoOpts, { limit: pageSize, skip: mongoOpts.skip || (page - 1) * pageSize })
505
+ }
506
+
507
+ /**
508
+ * Invokes the access check hook to allow modules to determine whether the request user has sufficient access to the requested resource(s)
509
+ * @param {external:ExpressRequest} req
510
+ * @param {Object} data The data to be checked
511
+ * @return {Promise} Rejects if access should be blocked
512
+ */
513
+ async checkAccess (req, data) {
514
+ const isArray = Array.isArray(data)
515
+ const filtered = []
516
+ let error
517
+ await Promise.allSettled((isArray ? data : [data]).map(async r => {
518
+ try {
519
+ if (!this.accessCheckHook.hasObservers || req.auth.isSuper ||
520
+ (await this.accessCheckHook.invoke(req, r)).some(Boolean)) {
521
+ filtered.push(r)
522
+ }
523
+ } catch (e) {
524
+ error = this.app.errors.UNAUTHORISED
525
+ .setData({ method: req.method, url: req.url })
526
+ }
527
+ }))
528
+ if (isArray) return filtered // we can ignore errors for arrays
529
+ if (error) throw error
530
+ return filtered[0]
531
+ }
532
+
533
+ /**
534
+ * Inserts a new document into the DB
535
+ * @param {Object} data Data to be inserted into the DB
536
+ * @param {InsertOptions} options Function options
537
+ * @param {external:MongoDBInsertOneOptions} mongoOptions Options to be passed to the MongoDB function
538
+ * @return {Promise} Resolves with DB data
539
+ */
540
+ async insert (data, options = {}, mongoOptions = {}) {
541
+ options.schemaName = options.schemaName ?? await this.getSchemaName(data)
542
+ this.setDefaultOptions(options)
543
+ if (options.invokePreHook !== false) await this.preInsertHook.invoke(data, options, mongoOptions)
544
+
545
+ data = await this.validate(options.schemaName, data, options)
546
+ const mongodb = await this.app.waitForModule('mongodb')
547
+ const results = await mongodb.insert(options.collectionName, data, mongoOptions)
548
+
549
+ if (options.invokePostHook !== false) await this.postInsertHook.invoke(results)
550
+ return results
551
+ }
552
+
553
+ /**
554
+ * Retrieves documents from the DB
555
+ * @param {Object} query Attributes to use to filter DB documents
556
+ * @param {FindOptions} options Function options
557
+ * @param {external:MongoDBFindOptions} mongoOptions Options to be passed to the MongoDB function
558
+ * @return {Promise} Resolves with DB data
559
+ */
560
+ async find (query, options = {}, mongoOptions = {}) {
561
+ this.setDefaultOptions(options)
562
+ const mongodb = await this.app.waitForModule('mongodb')
563
+ const q = options.validate ? await this.parseQuery(options.schemaName, query, options, mongoOptions) : query
564
+ return mongodb.find(options.collectionName, q, mongoOptions)
565
+ }
566
+
567
+ /**
568
+ * Updates an existing document in the DB
569
+ * @param {Object} query Attributes to use to filter DB documents
570
+ * @param {Object} data Data to be inserted into the DB
571
+ * @param {UpdateOptions} options Function options
572
+ * @param {external:MongoDBFindOneAndUpdateOptions} mongoOptions Options to be passed to the MongoDB function
573
+ * @return {Promise} Resolves with DB data
574
+ */
575
+ async update (query, data, options = {}, mongoOptions = {}) {
576
+ options.schemaName = options.schemaName ?? await this.getSchemaName(data)
577
+ this.setDefaultOptions(options)
578
+
579
+ const mongodb = await this.app.waitForModule('mongodb')
580
+ const [originalDoc] = await mongodb.find(options.collectionName, query)
581
+
582
+ if (!originalDoc) {
583
+ throw this.app.errors.NOT_FOUND.setData({ id: query._id ?? query.toString(), type: options.schemaName })
584
+ }
585
+ const formattedData = options.rawUpdate ? { $set: {}, ...data } : { $set: data }
586
+
587
+ if (options.invokePreHook !== false) await this.preUpdateHook.invoke(originalDoc, formattedData.$set, options, mongoOptions)
588
+ formattedData.$set = await this.validate(options.schemaName, {
589
+ ...ApiUtils.stringifyValues(originalDoc),
590
+ ...formattedData.$set
591
+ }, options)
592
+
593
+ const results = await mongodb.update(options.collectionName, query, formattedData, mongoOptions)
594
+ if (options.invokePostHook !== false) await this.postUpdateHook.invoke(originalDoc, results)
595
+ return results
596
+ }
597
+
598
+ /**
599
+ * Updates existing documents in the DB
600
+ * @param {Object} query Attributes to use to filter DB documents
601
+ * @param {Object} data Data to be inserted into the DB
602
+ * @param {UpdateOptions} options Function options
603
+ * @param {external:MongoDBUpdateOptions} mongoOptions Options to be passed to the MongoDB function
604
+ * @return {Promise} Resolves with DB data
605
+ */
606
+ async updateMany (query, data, options = {}, mongoOptions = {}) {
607
+ options.schemaName = options.schemaName ?? await this.getSchemaName(data)
608
+ this.setDefaultOptions(options)
609
+
610
+ const mongodb = await this.app.waitForModule('mongodb')
611
+ const originalDocs = await mongodb.find(options.collectionName, query)
612
+
613
+ if (!originalDocs.length) {
614
+ return []
615
+ }
616
+ const formattedData = options.rawUpdate ? { $set: {}, ...data } : { $set: data }
617
+
618
+ if (options.invokePreHook !== false) await Promise.all(originalDocs.map(d => this.preUpdateHook.invoke(d, formattedData.$set, options, mongoOptions)))
619
+ formattedData.$set = await this.validate(options.schemaName, formattedData.$set, options)
620
+
621
+ const updatedDocs = await mongodb.updateMany(options.collectionName, query, formattedData, mongoOptions)
622
+
623
+ if (options.invokePostHook !== false) await Promise.all(updatedDocs.map(d => this.postUpdateHook.invoke(d, updatedDocs)))
624
+ return updatedDocs
625
+ }
626
+
627
+ /**
628
+ * Removes a single document from the DB
629
+ * @param {Object} query Attributes to use to filter DB documents
630
+ * @param {DeleteOptions} options Function options
631
+ * @param {external:MongoDBDeleteOptions} mongoOptions Options to be passed to the MongoDB function
632
+ * @return {Promise} Resolves with DB data
633
+ */
634
+ async delete (query, options = {}, mongoOptions = {}) {
635
+ this.setDefaultOptions(options)
636
+
637
+ const mongodb = await this.app.waitForModule('mongodb')
638
+ const [originalDoc] = await mongodb.find(options.collectionName, query)
639
+
640
+ if (!originalDoc) {
641
+ throw this.app.errors.NOT_FOUND.setData({ id: query._id ?? query.toString(), type: options.schemaName })
642
+ }
643
+ if (options.invokePreHook !== false) await this.preDeleteHook.invoke(originalDoc, options, mongoOptions)
644
+ await mongodb.delete(options.collectionName, query, mongoOptions)
645
+ if (options.invokePostHook !== false) await this.postDeleteHook.invoke(originalDoc)
646
+ return originalDoc
647
+ }
648
+
649
+ /**
650
+ * Removes multiple documents from the DB
651
+ * @param {Object} query Attributes to use to filter DB documents
652
+ * @param {DeleteOptions} options Function options
653
+ * @param {external:MongoDBDeleteOptions} mongoOptions Options to be passed to the MongoDB function
654
+ * @return {Promise}
655
+ */
656
+ async deleteMany (query, options = {}, mongoOptions = {}) {
657
+ this.setDefaultOptions(options)
658
+
659
+ const mongodb = await this.app.waitForModule('mongodb')
660
+ const toDelete = await mongodb.find(options.collectionName, query)
661
+ await mongodb.deleteMany(options.collectionName, query, mongoOptions)
662
+
663
+ if (options.invokePostHook !== false) await Promise.all(toDelete.map(d => this.postDeleteHook.invoke(d)))
664
+ return toDelete
665
+ }
666
+ }
667
+
668
+ export default AbstractApiModule