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.
- package/.eslintignore +1 -0
- package/.eslintrc +14 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +55 -0
- package/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +22 -0
- package/.github/dependabot.yml +11 -0
- package/.github/pull_request_template.md +25 -0
- package/.github/workflows/labelled_prs.yml +16 -0
- package/.github/workflows/new.yml +19 -0
- package/adapt-authoring.json +9 -0
- package/conf/config.schema.json +22 -0
- package/docs/writing-an-api.md +135 -0
- package/errors/errors.json +32 -0
- package/index.js +7 -0
- package/lib/AbstractApiModule.js +668 -0
- package/lib/AbstractApiUtils.js +145 -0
- package/lib/DataCache.js +46 -0
- package/lib/typedefs.js +67 -0
- package/package.json +23 -0
- package/tests/abstractApiModule.spec.js +84 -0
- package/tests/abstractApiUtils.spec.js +49 -0
- package/tests/data/testApiModule.js +50 -0
|
@@ -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
|