adapt-authoring-api 3.1.4 → 3.2.0

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.
@@ -49,6 +49,12 @@ class AbstractApiModule extends AbstractModule {
49
49
  * @type {Hook}
50
50
  */
51
51
  this.preInsertHook = new Hook({ mutable: true })
52
+ /**
53
+ * Middleware hook that wraps the entire insert operation (pre-hook, validate, write, post-hook).
54
+ * Observers receive (next, data, options, mongoOptions) and must call next() to continue.
55
+ * @type {Hook}
56
+ */
57
+ this.insertHook = new Hook({ type: Hook.Types.Middleware })
52
58
  /**
53
59
  * Hook invoked after data is inserted into the database
54
60
  * @type {Hook}
@@ -59,6 +65,12 @@ class AbstractApiModule extends AbstractModule {
59
65
  * @type {Hook}
60
66
  */
61
67
  this.preUpdateHook = new Hook({ mutable: true })
68
+ /**
69
+ * Middleware hook that wraps the entire update operation (pre-hook, validate, write, post-hook).
70
+ * Observers receive (next, query, data, options, mongoOptions) and must call next() to continue.
71
+ * @type {Hook}
72
+ */
73
+ this.updateHook = new Hook({ type: Hook.Types.Middleware })
62
74
  /**
63
75
  * Hook invoked after data is updated in the database
64
76
  * @type {Hook}
@@ -69,6 +81,12 @@ class AbstractApiModule extends AbstractModule {
69
81
  * @type {Hook}
70
82
  */
71
83
  this.preDeleteHook = new Hook()
84
+ /**
85
+ * Middleware hook that wraps the entire delete operation (pre-hook, validate, write, post-hook).
86
+ * Observers receive (next, query, options, mongoOptions) and must call next() to continue.
87
+ * @type {Hook}
88
+ */
89
+ this.deleteHook = new Hook({ type: Hook.Types.Middleware })
72
90
  /**
73
91
  * Hook invoked after data is deleted
74
92
  * @type {Hook}
@@ -79,7 +97,7 @@ class AbstractApiModule extends AbstractModule {
79
97
  * @type {Hook}
80
98
  */
81
99
  this.accessCheckHook = new Hook()
82
-
100
+
83
101
  await this.setValues()
84
102
  this.validateValues()
85
103
  await this.addRoutes()
@@ -123,7 +141,12 @@ class AbstractApiModule extends AbstractModule {
123
141
 
124
142
  const config = await loadRouteConfig(this.rootDir, this, {
125
143
  schema: 'apiroutes',
126
- defaults: `${import.meta.dirname}/../default-routes.json`
144
+ handlerAliases: {
145
+ default: this.requestHandler(),
146
+ query: this.queryHandler(),
147
+ serveSchema: this.serveSchema.bind(this)
148
+ },
149
+ defaults: new URL('./default-routes.json', import.meta.url).pathname
127
150
  })
128
151
  if (config) this.applyRouteConfig(config)
129
152
  }
@@ -357,129 +380,135 @@ class AbstractApiModule extends AbstractModule {
357
380
  * Middleware to handle a generic API request. Supports POST, GET, PUT and DELETE of items in the database.
358
381
  * @return {Function} Express middleware function
359
382
  */
360
- async requestHandler (req, res, next) {
361
- const method = req.method.toLowerCase()
362
- const func = this[httpMethodToDBFunction(method)]
363
- if (!func) {
364
- return next(this.app.errors.HTTP_METHOD_NOT_SUPPORTED.setData({ method }))
365
- }
366
- let data
367
- try {
368
- await this.requestHook.invoke(req)
369
- const preCheck = method !== 'get' && method !== 'post'
370
- const postCheck = method === 'get'
371
- if (preCheck) {
372
- await this.checkAccess(req, req.apiData.query)
383
+ requestHandler () {
384
+ const requestHandler = async (req, res, next) => {
385
+ const method = req.method.toLowerCase()
386
+ const func = this[httpMethodToDBFunction(method)]
387
+ if (!func) {
388
+ return next(this.app.errors.HTTP_METHOD_NOT_SUPPORTED.setData({ method }))
373
389
  }
374
- data = await func.apply(this, argsFromReq(req))
375
- if (postCheck) {
376
- data = await this.checkAccess(req, data)
390
+ let data
391
+ try {
392
+ await this.requestHook.invoke(req)
393
+ const preCheck = method !== 'get' && method !== 'post'
394
+ const postCheck = method === 'get'
395
+ if (preCheck) {
396
+ await this.checkAccess(req, req.apiData.query)
397
+ }
398
+ data = await func.apply(this, argsFromReq(req))
399
+ if (postCheck) {
400
+ data = await this.checkAccess(req, data)
401
+ }
402
+ data = await this.sanitise(req.apiData.schemaName, data, { isInternal: true, strict: false })
403
+ } catch (e) {
404
+ return next(e)
377
405
  }
378
- data = await this.sanitise(req.apiData.schemaName, data, { isInternal: true, strict: false })
379
- } catch (e) {
380
- return next(e)
381
- }
382
- if (Array.isArray(data) && req.params._id) { // special case for when _id param is present
383
- if (!data.length) {
384
- return next(this.app.errors.NOT_FOUND.setData({ id: req.params._id, type: req.apiData.schemaName }))
406
+ if (Array.isArray(data) && req.params._id) { // special case for when _id param is present
407
+ if (!data.length) {
408
+ return next(this.app.errors.NOT_FOUND.setData({ id: req.params._id, type: req.apiData.schemaName }))
409
+ }
410
+ data = data[0]
385
411
  }
386
- data = data[0]
387
- }
388
- if (method !== 'get') {
389
- const resource = Array.isArray(data) ? req.apiData.query : data._id.toString()
390
- this.log('debug', `API_${func.name.toUpperCase()}`, resource, 'by', req.auth.user._id.toString())
412
+ if (method !== 'get') {
413
+ const resource = Array.isArray(data) ? req.apiData.query : data._id.toString()
414
+ this.log('debug', `API_${func.name.toUpperCase()}`, resource, 'by', req.auth.user._id.toString())
415
+ }
416
+ res.status(this.mapStatusCode(method)).json(data)
391
417
  }
392
- res.status(this.mapStatusCode(method)).json(data)
418
+ return requestHandler
393
419
  }
394
420
 
395
421
  /**
396
422
  * 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.
397
423
  * @return {function}
398
424
  */
399
- async queryHandler (req, res, next) {
400
- try {
401
- const opts = {
402
- schemaName: req.apiData.schemaName,
403
- collectionName: req.apiData.collectionName
404
- }
405
- const mongoOpts = {}
406
- // find and remove mongo options from the query
407
- Object.entries(req.apiData.query).forEach(([key, val]) => {
408
- if (['collation', 'limit', 'page', 'skip', 'sort'].includes(key)) {
409
- try {
410
- mongoOpts[key] = JSON.parse(req.apiData.query[key])
411
- } catch (e) {
412
- this.log('warn', `failed to parse query ${key} param '${mongoOpts[key]}', ${e}`)
413
- }
414
- delete req.apiData.query[key]
415
- } else {
416
- // otherwise assume we have a query field or option and store for later processing
417
- opts[key] = val
425
+ queryHandler () {
426
+ const queryHandler = async (req, res, next) => {
427
+ try {
428
+ const opts = {
429
+ schemaName: req.apiData.schemaName,
430
+ collectionName: req.apiData.collectionName
418
431
  }
419
- })
420
- // handle search parameter
421
- const search = req.apiData.query.search
422
- if (search) {
423
- delete req.apiData.query.search
424
- try {
425
- const schema = await this.getSchema(req.apiData.schemaName)
426
- if (schema && schema.built && schema.built.properties) {
427
- const searchableFields = Object.keys(schema.built.properties).filter(
428
- field => schema.built.properties[field].isSearchable === true
429
- )
430
- if (searchableFields.length) {
431
- // escape special regex characters to prevent ReDoS attacks
432
- const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
433
- const regex = { $regex: escapedSearch, $options: 'i' }
434
- const searchConditions = searchableFields.map(f => ({ [f]: regex }))
435
- // merge with existing $or if present
436
- if (req.apiData.query.$or) {
437
- req.apiData.query.$and = [
438
- { $or: req.apiData.query.$or },
439
- { $or: searchConditions }
440
- ]
441
- delete req.apiData.query.$or
442
- } else {
443
- req.apiData.query.$or = searchConditions
432
+ const mongoOpts = {}
433
+ // find and remove mongo options from the query
434
+ Object.entries(req.apiData.query).forEach(([key, val]) => {
435
+ if (['collation', 'limit', 'page', 'skip', 'sort'].includes(key)) {
436
+ try {
437
+ mongoOpts[key] = JSON.parse(req.apiData.query[key])
438
+ } catch (e) {
439
+ this.log('warn', `failed to parse query ${key} param '${mongoOpts[key]}', ${e}`)
440
+ }
441
+ delete req.apiData.query[key]
442
+ } else {
443
+ // otherwise assume we have a query field or option and store for later processing
444
+ opts[key] = val
445
+ }
446
+ })
447
+ // handle search parameter
448
+ const search = req.apiData.query.search
449
+ if (search) {
450
+ delete req.apiData.query.search
451
+ try {
452
+ const schema = await this.getSchema(req.apiData.schemaName)
453
+ if (schema && schema.built && schema.built.properties) {
454
+ const searchableFields = Object.keys(schema.built.properties).filter(
455
+ field => schema.built.properties[field].isSearchable === true
456
+ )
457
+ if (searchableFields.length) {
458
+ // escape special regex characters to prevent ReDoS attacks
459
+ const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
460
+ const regex = { $regex: escapedSearch, $options: 'i' }
461
+ const searchConditions = searchableFields.map(f => ({ [f]: regex }))
462
+ // merge with existing $or if present
463
+ if (req.apiData.query.$or) {
464
+ req.apiData.query.$and = [
465
+ { $or: req.apiData.query.$or },
466
+ { $or: searchConditions }
467
+ ]
468
+ delete req.apiData.query.$or
469
+ } else {
470
+ req.apiData.query.$or = searchConditions
471
+ }
444
472
  }
445
473
  }
474
+ } catch (e) {
475
+ this.log('warn', `failed to process search parameter, ${e.message}`)
446
476
  }
447
- } catch (e) {
448
- this.log('warn', `failed to process search parameter, ${e.message}`)
449
477
  }
450
- }
451
- req.apiData.query = await this.parseQuery(req.apiData.schemaName, req.body, mongoOpts)
452
- // remove any valid query keys from the options
453
- Object.keys(req.apiData.query).forEach(key => delete opts[key])
478
+ req.apiData.query = await this.parseQuery(req.apiData.schemaName, req.body, mongoOpts)
479
+ // remove any valid query keys from the options
480
+ Object.keys(req.apiData.query).forEach(key => delete opts[key])
454
481
 
455
- await this.requestHook.invoke(req)
482
+ await this.requestHook.invoke(req)
456
483
 
457
- await this.setUpPagination(req, res, mongoOpts)
484
+ await this.setUpPagination(req, res, mongoOpts)
458
485
 
459
- let results = await this.find(req.apiData.query, opts, mongoOpts)
486
+ let results = await this.find(req.apiData.query, opts, mongoOpts)
460
487
 
461
- results = await this.checkAccess(req, results)
488
+ results = await this.checkAccess(req, results)
462
489
 
463
- // If checkAccess filtered some results, fetch more to fill the page
464
- const pageSize = mongoOpts.limit
465
- if (pageSize && results.length < pageSize) {
466
- let fetchSkip = mongoOpts.skip + pageSize
467
- while (results.length < pageSize) {
468
- const extra = await this.find(req.apiData.query, opts, { ...mongoOpts, skip: fetchSkip })
469
- if (!extra.length) break
470
- const filtered = await this.checkAccess(req, extra)
471
- results = results.concat(filtered)
472
- fetchSkip += extra.length
490
+ // If checkAccess filtered some results, fetch more to fill the page
491
+ const pageSize = mongoOpts.limit
492
+ if (pageSize && results.length < pageSize) {
493
+ let fetchSkip = mongoOpts.skip + pageSize
494
+ while (results.length < pageSize) {
495
+ const extra = await this.find(req.apiData.query, opts, { ...mongoOpts, skip: fetchSkip })
496
+ if (!extra.length) break
497
+ const filtered = await this.checkAccess(req, extra)
498
+ results = results.concat(filtered)
499
+ fetchSkip += extra.length
500
+ }
501
+ if (results.length > pageSize) results = results.slice(0, pageSize)
473
502
  }
474
- if (results.length > pageSize) results = results.slice(0, pageSize)
475
- }
476
503
 
477
- results = await this.sanitise(req.apiData.schemaName, results, { isInternal: true, strict: false })
504
+ results = await this.sanitise(req.apiData.schemaName, results, { isInternal: true, strict: false })
478
505
 
479
- res.status(this.mapStatusCode('get')).json(results)
480
- } catch (e) {
481
- return next(e)
506
+ res.status(this.mapStatusCode('get')).json(results)
507
+ } catch (e) {
508
+ return next(e)
509
+ }
482
510
  }
511
+ return queryHandler
483
512
  }
484
513
 
485
514
  /**
@@ -594,16 +623,22 @@ class AbstractApiModule extends AbstractModule {
594
623
  * @return {Promise} Resolves with DB data
595
624
  */
596
625
  async insert (data, options = {}, mongoOptions = {}) {
597
- options.schemaName = options.schemaName ?? await this.getSchemaName(data)
598
- this.setDefaultOptions(options)
599
- if (options.invokePreHook !== false) await this.preInsertHook.invoke(data, options, mongoOptions)
626
+ const core = async (data, options, mongoOptions) => {
627
+ options.schemaName = options.schemaName ?? await this.getSchemaName(data)
628
+ this.setDefaultOptions(options)
629
+ if (options.invokePreHook !== false) await this.preInsertHook.invoke(data, options, mongoOptions)
600
630
 
601
- data = await this.validate(options.schemaName, data, options)
602
- const mongodb = await this.app.waitForModule('mongodb')
603
- const results = await mongodb.insert(options.collectionName, data, mongoOptions)
631
+ data = await this.validate(options.schemaName, data, options)
632
+ const mongodb = await this.app.waitForModule('mongodb')
633
+ const results = await mongodb.insert(options.collectionName, data, mongoOptions)
604
634
 
605
- if (options.invokePostHook !== false) await this.postInsertHook.invoke(results)
606
- return results
635
+ if (options.invokePostHook !== false) await this.postInsertHook.invoke(results)
636
+ return results
637
+ }
638
+ if (this.insertHook.hasObservers) {
639
+ return this.insertHook.invoke(core, data, options, mongoOptions)
640
+ }
641
+ return core(data, options, mongoOptions)
607
642
  }
608
643
 
609
644
  /**
@@ -646,26 +681,32 @@ class AbstractApiModule extends AbstractModule {
646
681
  * @return {Promise} Resolves with DB data
647
682
  */
648
683
  async update (query, data, options = {}, mongoOptions = {}) {
649
- options.schemaName = options.schemaName ?? await this.getSchemaName(data)
650
- this.setDefaultOptions(options)
684
+ const core = async (query, data, options, mongoOptions) => {
685
+ options.schemaName = options.schemaName ?? await this.getSchemaName(data)
686
+ this.setDefaultOptions(options)
651
687
 
652
- const mongodb = await this.app.waitForModule('mongodb')
653
- const [originalDoc] = await mongodb.find(options.collectionName, query)
688
+ const mongodb = await this.app.waitForModule('mongodb')
689
+ const [originalDoc] = await mongodb.find(options.collectionName, query)
654
690
 
655
- if (!originalDoc) {
656
- throw this.app.errors.NOT_FOUND.setData({ id: query._id ?? query.toString(), type: options.schemaName })
657
- }
658
- const formattedData = options.rawUpdate ? { $set: {}, ...data } : { $set: data }
691
+ if (!originalDoc) {
692
+ throw this.app.errors.NOT_FOUND.setData({ id: query._id ?? query.toString(), type: options.schemaName })
693
+ }
694
+ const formattedData = options.rawUpdate ? { $set: {}, ...data } : { $set: data }
659
695
 
660
- if (options.invokePreHook !== false) await this.preUpdateHook.invoke(originalDoc, formattedData.$set, options, mongoOptions)
661
- formattedData.$set = await this.validate(options.schemaName, {
662
- ...stringifyValues(originalDoc),
663
- ...formattedData.$set
664
- }, options)
696
+ if (options.invokePreHook !== false) await this.preUpdateHook.invoke(originalDoc, formattedData.$set, options, mongoOptions)
697
+ formattedData.$set = await this.validate(options.schemaName, {
698
+ ...stringifyValues(originalDoc),
699
+ ...formattedData.$set
700
+ }, options)
665
701
 
666
- const results = await mongodb.update(options.collectionName, query, formattedData, mongoOptions)
667
- if (options.invokePostHook !== false) await this.postUpdateHook.invoke(originalDoc, results)
668
- return results
702
+ const results = await mongodb.update(options.collectionName, query, formattedData, mongoOptions)
703
+ if (options.invokePostHook !== false) await this.postUpdateHook.invoke(originalDoc, results)
704
+ return results
705
+ }
706
+ if (this.updateHook.hasObservers) {
707
+ return this.updateHook.invoke(core, query, data, options, mongoOptions)
708
+ }
709
+ return core(query, data, options, mongoOptions)
669
710
  }
670
711
 
671
712
  /**
@@ -705,18 +746,24 @@ class AbstractApiModule extends AbstractModule {
705
746
  * @return {Promise} Resolves with DB data
706
747
  */
707
748
  async delete (query, options = {}, mongoOptions = {}) {
708
- this.setDefaultOptions(options)
749
+ const core = async (query, options, mongoOptions) => {
750
+ this.setDefaultOptions(options)
709
751
 
710
- const mongodb = await this.app.waitForModule('mongodb')
711
- const [originalDoc] = await mongodb.find(options.collectionName, query)
752
+ const mongodb = await this.app.waitForModule('mongodb')
753
+ const [originalDoc] = await mongodb.find(options.collectionName, query)
712
754
 
713
- if (!originalDoc) {
714
- throw this.app.errors.NOT_FOUND.setData({ id: query._id ?? query.toString(), type: options.schemaName })
755
+ if (!originalDoc) {
756
+ throw this.app.errors.NOT_FOUND.setData({ id: query._id ?? query.toString(), type: options.schemaName })
757
+ }
758
+ if (options.invokePreHook !== false) await this.preDeleteHook.invoke(originalDoc, options, mongoOptions)
759
+ await mongodb.delete(options.collectionName, query, mongoOptions)
760
+ if (options.invokePostHook !== false) await this.postDeleteHook.invoke(originalDoc)
761
+ return originalDoc
762
+ }
763
+ if (this.deleteHook.hasObservers) {
764
+ return this.deleteHook.invoke(core, query, options, mongoOptions)
715
765
  }
716
- if (options.invokePreHook !== false) await this.preDeleteHook.invoke(originalDoc, options, mongoOptions)
717
- await mongodb.delete(options.collectionName, query, mongoOptions)
718
- if (options.invokePostHook !== false) await this.postDeleteHook.invoke(originalDoc)
719
- return originalDoc
766
+ return core(query, options, mongoOptions)
720
767
  }
721
768
 
722
769
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-api",
3
- "version": "3.1.4",
3
+ "version": "3.2.0",
4
4
  "description": "Abstract module for creating APIs",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-api",
6
6
  "license": "GPL-3.0",