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.
- package/lib/AbstractApiModule.js +181 -134
- package/package.json +1 -1
package/lib/AbstractApiModule.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
482
|
+
await this.requestHook.invoke(req)
|
|
456
483
|
|
|
457
|
-
|
|
484
|
+
await this.setUpPagination(req, res, mongoOpts)
|
|
458
485
|
|
|
459
|
-
|
|
486
|
+
let results = await this.find(req.apiData.query, opts, mongoOpts)
|
|
460
487
|
|
|
461
|
-
|
|
488
|
+
results = await this.checkAccess(req, results)
|
|
462
489
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
504
|
+
results = await this.sanitise(req.apiData.schemaName, results, { isInternal: true, strict: false })
|
|
478
505
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
606
|
-
|
|
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
|
-
|
|
650
|
-
|
|
684
|
+
const core = async (query, data, options, mongoOptions) => {
|
|
685
|
+
options.schemaName = options.schemaName ?? await this.getSchemaName(data)
|
|
686
|
+
this.setDefaultOptions(options)
|
|
651
687
|
|
|
652
|
-
|
|
653
|
-
|
|
688
|
+
const mongodb = await this.app.waitForModule('mongodb')
|
|
689
|
+
const [originalDoc] = await mongodb.find(options.collectionName, query)
|
|
654
690
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
749
|
+
const core = async (query, options, mongoOptions) => {
|
|
750
|
+
this.setDefaultOptions(options)
|
|
709
751
|
|
|
710
|
-
|
|
711
|
-
|
|
752
|
+
const mongodb = await this.app.waitForModule('mongodb')
|
|
753
|
+
const [originalDoc] = await mongodb.find(options.collectionName, query)
|
|
712
754
|
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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
|
/**
|