adapt-authoring-api 2.1.4 → 3.0.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/docs/writing-an-api.md +2 -0
- package/lib/AbstractApiModule.js +150 -108
- package/lib/default-routes.json +148 -0
- package/lib/utils/generateApiMetadata.js +1 -0
- package/package.json +3 -2
- package/schema/apiroutes.schema.json +28 -0
- package/tests/AbstractApiModule.spec.js +122 -0
package/docs/writing-an-api.md
CHANGED
|
@@ -53,6 +53,8 @@ class NotesModule extends AbstractApiModule {
|
|
|
53
53
|
export default NotesModule
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
> **Tip:** For new modules, consider defining routes in a `routes.json` file instead of calling `useDefaultRouteConfig()`. See [Custom routes](#custom-routes) for details.
|
|
57
|
+
|
|
56
58
|
With a schema at `schema/note.schema.json`:
|
|
57
59
|
|
|
58
60
|
```json
|
package/lib/AbstractApiModule.js
CHANGED
|
@@ -2,6 +2,7 @@ import _ from 'lodash'
|
|
|
2
2
|
import { AbstractModule, Hook, stringifyValues } from 'adapt-authoring-core'
|
|
3
3
|
import { argsFromReq, generateApiMetadata, httpMethodToDBFunction } from './utils.js'
|
|
4
4
|
import DataCache from './DataCache.js'
|
|
5
|
+
import { loadRouteConfig } from 'adapt-authoring-server'
|
|
5
6
|
/**
|
|
6
7
|
* Abstract module for creating APIs
|
|
7
8
|
* @memberof api
|
|
@@ -11,11 +12,11 @@ class AbstractApiModule extends AbstractModule {
|
|
|
11
12
|
get DEFAULT_ROUTES () {
|
|
12
13
|
const readPerms = [`read:${this.permissionsScope || this.root}`]
|
|
13
14
|
const writePerms = [`write:${this.permissionsScope || this.root}`]
|
|
14
|
-
const handler = this.requestHandler()
|
|
15
|
+
const handler = this.requestHandler.bind(this)
|
|
15
16
|
return [
|
|
16
17
|
{
|
|
17
18
|
route: '/',
|
|
18
|
-
handlers: { post: handler, get: this.queryHandler() },
|
|
19
|
+
handlers: { post: handler, get: this.queryHandler.bind(this) },
|
|
19
20
|
permissions: { post: writePerms, get: readPerms }
|
|
20
21
|
},
|
|
21
22
|
{
|
|
@@ -32,7 +33,7 @@ class AbstractApiModule extends AbstractModule {
|
|
|
32
33
|
route: '/query',
|
|
33
34
|
validate: false,
|
|
34
35
|
modifying: false,
|
|
35
|
-
handlers: { post: this.queryHandler() },
|
|
36
|
+
handlers: { post: this.queryHandler.bind(this) },
|
|
36
37
|
permissions: { post: readPerms }
|
|
37
38
|
}
|
|
38
39
|
]
|
|
@@ -149,6 +150,12 @@ class AbstractApiModule extends AbstractModule {
|
|
|
149
150
|
* @type {String}
|
|
150
151
|
*/
|
|
151
152
|
this.schemaName = undefined
|
|
153
|
+
|
|
154
|
+
const config = await loadRouteConfig(this.rootDir, this, {
|
|
155
|
+
schema: 'apiroutes',
|
|
156
|
+
defaults: new URL('./default-routes.json', import.meta.url).pathname
|
|
157
|
+
})
|
|
158
|
+
if (config) this.applyRouteConfig(config)
|
|
152
159
|
}
|
|
153
160
|
|
|
154
161
|
/**
|
|
@@ -189,6 +196,7 @@ class AbstractApiModule extends AbstractModule {
|
|
|
189
196
|
|
|
190
197
|
/**
|
|
191
198
|
* Checks required values have been set
|
|
199
|
+
* @deprecated Validation is handled by the routes schema for modules using routes.json
|
|
192
200
|
*/
|
|
193
201
|
validateValues () {
|
|
194
202
|
if (!this.root && !this.router) {
|
|
@@ -226,6 +234,46 @@ class AbstractApiModule extends AbstractModule {
|
|
|
226
234
|
Object.values(uniqueRoutes).forEach(r => this.addRoute(r, auth))
|
|
227
235
|
}
|
|
228
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Applies route configuration loaded from routes.json.
|
|
239
|
+
* Resolves `${scope}`, `${schemaName}`, and `${collectionName}` placeholders
|
|
240
|
+
* throughout the route config (permissions, meta, etc.).
|
|
241
|
+
* @param {Object} config The route config object returned by loadRouteConfig
|
|
242
|
+
*/
|
|
243
|
+
applyRouteConfig (config) {
|
|
244
|
+
/** @ignore */ this.root = config.root
|
|
245
|
+
if (config.schemaName !== undefined) this.schemaName = config.schemaName
|
|
246
|
+
if (config.collectionName !== undefined) this.collectionName = config.collectionName
|
|
247
|
+
/* eslint-disable no-template-curly-in-string */
|
|
248
|
+
const replacements = {
|
|
249
|
+
'${scope}': this.permissionsScope || this.root,
|
|
250
|
+
'${schemaName}': this.schemaName,
|
|
251
|
+
'${collectionName}': this.collectionName
|
|
252
|
+
}
|
|
253
|
+
/* eslint-enable no-template-curly-in-string */
|
|
254
|
+
this.routes = config.routes.map(r => this.replacePlaceholders(r, replacements))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Recursively replaces placeholder strings in an object tree.
|
|
259
|
+
* Non-string values (functions, numbers, booleans, null) pass through unchanged.
|
|
260
|
+
* @param {*} obj The value to process
|
|
261
|
+
* @param {Object<string,string>} replacements Map of placeholder to replacement value
|
|
262
|
+
* @returns {*} The value with all placeholders resolved
|
|
263
|
+
*/
|
|
264
|
+
replacePlaceholders (obj, replacements) {
|
|
265
|
+
if (typeof obj === 'string') {
|
|
266
|
+
return Object.entries(replacements).reduce((s, [k, v]) => v != null ? s.replaceAll(k, v) : s, obj)
|
|
267
|
+
}
|
|
268
|
+
if (Array.isArray(obj)) return obj.map(item => this.replacePlaceholders(item, replacements))
|
|
269
|
+
if (obj && typeof obj === 'object' && obj.constructor === Object) {
|
|
270
|
+
return Object.fromEntries(
|
|
271
|
+
Object.entries(obj).map(([k, v]) => [k, this.replacePlaceholders(v, replacements)])
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
return obj
|
|
275
|
+
}
|
|
276
|
+
|
|
229
277
|
/**
|
|
230
278
|
* Adds a single route definition
|
|
231
279
|
* @param {Route} config The route config
|
|
@@ -353,135 +401,129 @@ class AbstractApiModule extends AbstractModule {
|
|
|
353
401
|
* Middleware to handle a generic API request. Supports POST, GET, PUT and DELETE of items in the database.
|
|
354
402
|
* @return {Function} Express middleware function
|
|
355
403
|
*/
|
|
356
|
-
requestHandler () {
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
await this.checkAccess(req, req.apiData.query)
|
|
370
|
-
}
|
|
371
|
-
data = await func.apply(this, argsFromReq(req))
|
|
372
|
-
if (postCheck) {
|
|
373
|
-
data = await this.checkAccess(req, data)
|
|
374
|
-
}
|
|
375
|
-
data = await this.sanitise(req.apiData.schemaName, data, { isInternal: true, strict: false })
|
|
376
|
-
} catch (e) {
|
|
377
|
-
return next(e)
|
|
404
|
+
async requestHandler (req, res, next) {
|
|
405
|
+
const method = req.method.toLowerCase()
|
|
406
|
+
const func = this[httpMethodToDBFunction(method)]
|
|
407
|
+
if (!func) {
|
|
408
|
+
return next(this.app.errors.HTTP_METHOD_NOT_SUPPORTED.setData({ method }))
|
|
409
|
+
}
|
|
410
|
+
let data
|
|
411
|
+
try {
|
|
412
|
+
await this.requestHook.invoke(req)
|
|
413
|
+
const preCheck = method !== 'get' && method !== 'post'
|
|
414
|
+
const postCheck = method === 'get'
|
|
415
|
+
if (preCheck) {
|
|
416
|
+
await this.checkAccess(req, req.apiData.query)
|
|
378
417
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
383
|
-
data = data[0]
|
|
418
|
+
data = await func.apply(this, argsFromReq(req))
|
|
419
|
+
if (postCheck) {
|
|
420
|
+
data = await this.checkAccess(req, data)
|
|
384
421
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
422
|
+
data = await this.sanitise(req.apiData.schemaName, data, { isInternal: true, strict: false })
|
|
423
|
+
} catch (e) {
|
|
424
|
+
return next(e)
|
|
425
|
+
}
|
|
426
|
+
if (Array.isArray(data) && req.params._id) { // special case for when _id param is present
|
|
427
|
+
if (!data.length) {
|
|
428
|
+
return next(this.app.errors.NOT_FOUND.setData({ id: req.params.id, type: req.apiData.schemaName }))
|
|
388
429
|
}
|
|
389
|
-
|
|
430
|
+
data = data[0]
|
|
431
|
+
}
|
|
432
|
+
if (method !== 'get') {
|
|
433
|
+
const resource = Array.isArray(data) ? req.apiData.query : data._id.toString()
|
|
434
|
+
this.log('debug', `API_${func.name.toUpperCase()}`, resource, 'by', req.auth.user._id.toString())
|
|
390
435
|
}
|
|
391
|
-
|
|
436
|
+
res.status(this.mapStatusCode(method)).json(data)
|
|
392
437
|
}
|
|
393
438
|
|
|
394
439
|
/**
|
|
395
440
|
* 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.
|
|
396
441
|
* @return {function}
|
|
397
442
|
*/
|
|
398
|
-
queryHandler () {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
|
418
|
-
}
|
|
419
|
-
})
|
|
420
|
-
// handle search parameter
|
|
421
|
-
const search = req.apiData.query.search
|
|
422
|
-
if (search) {
|
|
423
|
-
delete req.apiData.query.search
|
|
443
|
+
async queryHandler (req, res, next) {
|
|
444
|
+
try {
|
|
445
|
+
const opts = {
|
|
446
|
+
schemaName: req.apiData.schemaName,
|
|
447
|
+
collectionName: req.apiData.collectionName
|
|
448
|
+
}
|
|
449
|
+
const mongoOpts = {}
|
|
450
|
+
// find and remove mongo options from the query
|
|
451
|
+
Object.entries(req.apiData.query).forEach(([key, val]) => {
|
|
452
|
+
if (['collation', 'limit', 'page', 'skip', 'sort'].includes(key)) {
|
|
424
453
|
try {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
454
|
+
mongoOpts[key] = JSON.parse(req.apiData.query[key])
|
|
455
|
+
} catch (e) {
|
|
456
|
+
this.log('warn', `failed to parse query ${key} param '${mongoOpts[key]}', ${e}`)
|
|
457
|
+
}
|
|
458
|
+
delete req.apiData.query[key]
|
|
459
|
+
} else {
|
|
460
|
+
// otherwise assume we have a query field or option and store for later processing
|
|
461
|
+
opts[key] = val
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
// handle search parameter
|
|
465
|
+
const search = req.apiData.query.search
|
|
466
|
+
if (search) {
|
|
467
|
+
delete req.apiData.query.search
|
|
468
|
+
try {
|
|
469
|
+
const schema = await this.getSchema(req.apiData.schemaName)
|
|
470
|
+
if (schema && schema.built && schema.built.properties) {
|
|
471
|
+
const searchableFields = Object.keys(schema.built.properties).filter(
|
|
472
|
+
field => schema.built.properties[field].isSearchable === true
|
|
473
|
+
)
|
|
474
|
+
if (searchableFields.length) {
|
|
475
|
+
// escape special regex characters to prevent ReDoS attacks
|
|
476
|
+
const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
477
|
+
const regex = { $regex: escapedSearch, $options: 'i' }
|
|
478
|
+
const searchConditions = searchableFields.map(f => ({ [f]: regex }))
|
|
479
|
+
// merge with existing $or if present
|
|
480
|
+
if (req.apiData.query.$or) {
|
|
481
|
+
req.apiData.query.$and = [
|
|
482
|
+
{ $or: req.apiData.query.$or },
|
|
483
|
+
{ $or: searchConditions }
|
|
484
|
+
]
|
|
485
|
+
delete req.apiData.query.$or
|
|
486
|
+
} else {
|
|
487
|
+
req.apiData.query.$or = searchConditions
|
|
445
488
|
}
|
|
446
489
|
}
|
|
447
|
-
} catch (e) {
|
|
448
|
-
this.log('warn', `failed to process search parameter, ${e.message}`)
|
|
449
490
|
}
|
|
491
|
+
} catch (e) {
|
|
492
|
+
this.log('warn', `failed to process search parameter, ${e.message}`)
|
|
450
493
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
494
|
+
}
|
|
495
|
+
req.apiData.query = await this.parseQuery(req.apiData.schemaName, req.body, mongoOpts)
|
|
496
|
+
// remove any valid query keys from the options
|
|
497
|
+
Object.keys(req.apiData.query).forEach(key => delete opts[key])
|
|
454
498
|
|
|
455
|
-
|
|
499
|
+
await this.requestHook.invoke(req)
|
|
456
500
|
|
|
457
|
-
|
|
501
|
+
await this.setUpPagination(req, res, mongoOpts)
|
|
458
502
|
|
|
459
|
-
|
|
503
|
+
let results = await this.find(req.apiData.query, opts, mongoOpts)
|
|
460
504
|
|
|
461
|
-
|
|
505
|
+
results = await this.checkAccess(req, results)
|
|
462
506
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
}
|
|
474
|
-
if (results.length > pageSize) results = results.slice(0, pageSize)
|
|
507
|
+
// If checkAccess filtered some results, fetch more to fill the page
|
|
508
|
+
const pageSize = mongoOpts.limit
|
|
509
|
+
if (pageSize && results.length < pageSize) {
|
|
510
|
+
let fetchSkip = mongoOpts.skip + pageSize
|
|
511
|
+
while (results.length < pageSize) {
|
|
512
|
+
const extra = await this.find(req.apiData.query, opts, { ...mongoOpts, skip: fetchSkip })
|
|
513
|
+
if (!extra.length) break
|
|
514
|
+
const filtered = await this.checkAccess(req, extra)
|
|
515
|
+
results = results.concat(filtered)
|
|
516
|
+
fetchSkip += extra.length
|
|
475
517
|
}
|
|
518
|
+
if (results.length > pageSize) results = results.slice(0, pageSize)
|
|
519
|
+
}
|
|
476
520
|
|
|
477
|
-
|
|
521
|
+
results = await this.sanitise(req.apiData.schemaName, results, { isInternal: true, strict: false })
|
|
478
522
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
523
|
+
res.status(this.mapStatusCode('get')).json(results)
|
|
524
|
+
} catch (e) {
|
|
525
|
+
return next(e)
|
|
483
526
|
}
|
|
484
|
-
return queryHandler
|
|
485
527
|
}
|
|
486
528
|
|
|
487
529
|
/**
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
{
|
|
2
|
+
"routes": [
|
|
3
|
+
{
|
|
4
|
+
"route": "/",
|
|
5
|
+
"handlers": { "post": "requestHandler", "get": "queryHandler" },
|
|
6
|
+
"permissions": { "post": ["write:${scope}"], "get": ["read:${scope}"] },
|
|
7
|
+
"meta": {
|
|
8
|
+
"post": {
|
|
9
|
+
"summary": "Insert a new ${schemaName} document",
|
|
10
|
+
"requestBody": {
|
|
11
|
+
"content": {
|
|
12
|
+
"application/json": {
|
|
13
|
+
"schema": { "$ref": "#/components/schemas/${schemaName}" }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"responses": {
|
|
18
|
+
"201": {
|
|
19
|
+
"description": "The created ${schemaName} document",
|
|
20
|
+
"content": {
|
|
21
|
+
"application/json": {
|
|
22
|
+
"schema": { "$ref": "#/components/schemas/${schemaName}" }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"get": {
|
|
29
|
+
"summary": "Retrieve all ${collectionName} documents",
|
|
30
|
+
"parameters": [
|
|
31
|
+
{ "name": "limit", "in": "query", "description": "How many results to return" },
|
|
32
|
+
{ "name": "page", "in": "query", "description": "The page of results to return" }
|
|
33
|
+
],
|
|
34
|
+
"responses": {
|
|
35
|
+
"200": {
|
|
36
|
+
"description": "List of ${schemaName} documents",
|
|
37
|
+
"content": {
|
|
38
|
+
"application/json": {
|
|
39
|
+
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/${schemaName}" } }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"route": "/schema",
|
|
49
|
+
"handlers": { "get": "serveSchema" },
|
|
50
|
+
"permissions": { "get": ["read:schema"] },
|
|
51
|
+
"meta": {
|
|
52
|
+
"get": { "summary": "Retrieve ${schemaName} schema" }
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"route": "/:_id",
|
|
57
|
+
"handlers": { "put": "requestHandler", "get": "requestHandler", "patch": "requestHandler", "delete": "requestHandler" },
|
|
58
|
+
"permissions": { "put": ["write:${scope}"], "get": ["read:${scope}"], "patch": ["write:${scope}"], "delete": ["write:${scope}"] },
|
|
59
|
+
"meta": {
|
|
60
|
+
"put": {
|
|
61
|
+
"summary": "Replace an existing ${schemaName} document",
|
|
62
|
+
"requestBody": {
|
|
63
|
+
"content": {
|
|
64
|
+
"application/json": {
|
|
65
|
+
"schema": { "$ref": "#/components/schemas/${schemaName}" }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"responses": {
|
|
70
|
+
"200": {
|
|
71
|
+
"description": "The updated ${schemaName} document",
|
|
72
|
+
"content": {
|
|
73
|
+
"application/json": {
|
|
74
|
+
"schema": { "$ref": "#/components/schemas/${schemaName}" }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"get": {
|
|
81
|
+
"summary": "Retrieve an existing ${schemaName} document",
|
|
82
|
+
"responses": {
|
|
83
|
+
"200": {
|
|
84
|
+
"description": "The ${schemaName} document",
|
|
85
|
+
"content": {
|
|
86
|
+
"application/json": {
|
|
87
|
+
"schema": { "$ref": "#/components/schemas/${schemaName}" }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"patch": {
|
|
94
|
+
"summary": "Update an existing ${schemaName} document",
|
|
95
|
+
"requestBody": {
|
|
96
|
+
"content": {
|
|
97
|
+
"application/json": {
|
|
98
|
+
"schema": { "$ref": "#/components/schemas/${schemaName}" }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"responses": {
|
|
103
|
+
"200": {
|
|
104
|
+
"description": "The updated ${schemaName} document",
|
|
105
|
+
"content": {
|
|
106
|
+
"application/json": {
|
|
107
|
+
"schema": { "$ref": "#/components/schemas/${schemaName}" }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"delete": {
|
|
114
|
+
"summary": "Delete an existing ${schemaName} document",
|
|
115
|
+
"responses": {
|
|
116
|
+
"204": { "description": "Document deleted successfully" }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"route": "/query",
|
|
123
|
+
"validate": false,
|
|
124
|
+
"modifying": false,
|
|
125
|
+
"handlers": { "post": "queryHandler" },
|
|
126
|
+
"permissions": { "post": ["read:${scope}"] },
|
|
127
|
+
"meta": {
|
|
128
|
+
"post": {
|
|
129
|
+
"summary": "Query all ${collectionName}",
|
|
130
|
+
"parameters": [
|
|
131
|
+
{ "name": "limit", "in": "query", "description": "How many results to return" },
|
|
132
|
+
{ "name": "page", "in": "query", "description": "The page of results to return" }
|
|
133
|
+
],
|
|
134
|
+
"responses": {
|
|
135
|
+
"200": {
|
|
136
|
+
"description": "List of ${schemaName} documents",
|
|
137
|
+
"content": {
|
|
138
|
+
"application/json": {
|
|
139
|
+
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/${schemaName}" } }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Generates REST API metadata and stores on route config
|
|
3
3
|
* @param {AbstractApiModule} instance The current AbstractApiModule instance
|
|
4
4
|
* @memberof api
|
|
5
|
+
* @deprecated For modules with routes.json, define metadata in the meta field of each route entry instead
|
|
5
6
|
*/
|
|
6
7
|
export function generateApiMetadata (instance) {
|
|
7
8
|
const getData = isList => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-api",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.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",
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
"adapt-authoring-auth": "^2.0.0",
|
|
19
19
|
"adapt-authoring-jsonschema": "^1.2.0",
|
|
20
20
|
"adapt-authoring-mongodb": "^3.0.0",
|
|
21
|
-
"adapt-authoring-server": "^2.
|
|
21
|
+
"adapt-authoring-server": "^2.1.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@semantic-release/git": "^10.0.1",
|
|
25
|
+
"adapt-authoring-server": "^2.1.0",
|
|
25
26
|
"conventional-changelog-eslint": "^6.0.0",
|
|
26
27
|
"semantic-release": "^25.0.2",
|
|
27
28
|
"standard": "^17.1.0"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$anchor": "apiroutes",
|
|
4
|
+
"$merge": {
|
|
5
|
+
"source": { "$ref": "routes" },
|
|
6
|
+
"with": {
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaName": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "Schema name for the module's data model"
|
|
11
|
+
},
|
|
12
|
+
"collectionName": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"description": "MongoDB collection name"
|
|
15
|
+
},
|
|
16
|
+
"useDefaultRoutes": {
|
|
17
|
+
"type": "boolean",
|
|
18
|
+
"description": "Whether to generate default CRUD routes",
|
|
19
|
+
"default": true
|
|
20
|
+
},
|
|
21
|
+
"routes": {
|
|
22
|
+
"type": "array",
|
|
23
|
+
"items": { "$ref": "routeitem" }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -2,6 +2,20 @@ import { describe, it } from 'node:test'
|
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
import AbstractApiModule from '../lib/AbstractApiModule.js'
|
|
4
4
|
|
|
5
|
+
function createInstance (overrides = {}) {
|
|
6
|
+
const instance = Object.create(AbstractApiModule.prototype)
|
|
7
|
+
instance.root = 'test'
|
|
8
|
+
instance.permissionsScope = undefined
|
|
9
|
+
instance.schemaName = undefined
|
|
10
|
+
instance.collectionName = undefined
|
|
11
|
+
instance.routes = []
|
|
12
|
+
instance.requestHandler = function defaultRequestHandler () {}
|
|
13
|
+
instance.queryHandler = function queryHandler () {}
|
|
14
|
+
instance.serveSchema = function serveSchema () {}
|
|
15
|
+
Object.assign(instance, overrides)
|
|
16
|
+
return instance
|
|
17
|
+
}
|
|
18
|
+
|
|
5
19
|
describe('AbstractApiModule', () => {
|
|
6
20
|
describe('#mapStatusCode()', () => {
|
|
7
21
|
const instance = Object.create(AbstractApiModule.prototype)
|
|
@@ -56,4 +70,112 @@ describe('AbstractApiModule', () => {
|
|
|
56
70
|
assert.equal(options, undefined)
|
|
57
71
|
})
|
|
58
72
|
})
|
|
73
|
+
|
|
74
|
+
describe('#applyRouteConfig()', () => {
|
|
75
|
+
it('should set root, schemaName, and collectionName from config', async () => {
|
|
76
|
+
const instance = createInstance()
|
|
77
|
+
await instance.applyRouteConfig({
|
|
78
|
+
root: 'content',
|
|
79
|
+
schemaName: 'content',
|
|
80
|
+
collectionName: 'content',
|
|
81
|
+
useDefaultRoutes: false,
|
|
82
|
+
routes: []
|
|
83
|
+
})
|
|
84
|
+
assert.equal(instance.root, 'content')
|
|
85
|
+
assert.equal(instance.schemaName, 'content')
|
|
86
|
+
assert.equal(instance.collectionName, 'content')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should not override schemaName or collectionName when not in config', async () => {
|
|
90
|
+
const instance = createInstance({ schemaName: 'existing', collectionName: 'existing' })
|
|
91
|
+
await instance.applyRouteConfig({ root: 'content', useDefaultRoutes: false, routes: [] })
|
|
92
|
+
assert.equal(instance.schemaName, 'existing')
|
|
93
|
+
assert.equal(instance.collectionName, 'existing')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should set routes to custom routes only when useDefaultRoutes is false', async () => {
|
|
97
|
+
const instance = createInstance()
|
|
98
|
+
const customRoute = { route: '/custom', handlers: { get: () => {} } }
|
|
99
|
+
await instance.applyRouteConfig({ root: 'test', useDefaultRoutes: false, routes: [customRoute] })
|
|
100
|
+
assert.equal(instance.routes.length, 1)
|
|
101
|
+
assert.equal(instance.routes[0].route, '/custom')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should set routes to empty array when config has no routes', async () => {
|
|
105
|
+
const instance = createInstance()
|
|
106
|
+
await instance.applyRouteConfig({ root: 'test', routes: [] })
|
|
107
|
+
assert.deepEqual(instance.routes, [])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should expand ${scope} placeholders in permissions using root', async () => { // eslint-disable-line no-template-curly-in-string
|
|
111
|
+
const instance = createInstance({ root: 'content' })
|
|
112
|
+
await instance.applyRouteConfig({
|
|
113
|
+
root: 'content',
|
|
114
|
+
routes: [{ route: '/', permissions: { get: ['read:${scope}'], post: ['write:${scope}'] } }] // eslint-disable-line no-template-curly-in-string
|
|
115
|
+
})
|
|
116
|
+
assert.deepEqual(instance.routes[0].permissions.get, ['read:content'])
|
|
117
|
+
assert.deepEqual(instance.routes[0].permissions.post, ['write:content'])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should prefer permissionsScope over root for ${scope} expansion', async () => { // eslint-disable-line no-template-curly-in-string
|
|
121
|
+
const instance = createInstance({ root: 'content', permissionsScope: 'custom' })
|
|
122
|
+
await instance.applyRouteConfig({
|
|
123
|
+
root: 'content',
|
|
124
|
+
routes: [{ route: '/', permissions: { get: ['read:${scope}'] } }] // eslint-disable-line no-template-curly-in-string
|
|
125
|
+
})
|
|
126
|
+
assert.deepEqual(instance.routes[0].permissions.get, ['read:custom'])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should pass through null permissions unchanged', async () => {
|
|
130
|
+
const instance = createInstance({ root: 'content' })
|
|
131
|
+
await instance.applyRouteConfig({
|
|
132
|
+
root: 'content',
|
|
133
|
+
routes: [{ route: '/', permissions: { get: null } }]
|
|
134
|
+
})
|
|
135
|
+
assert.equal(instance.routes[0].permissions.get, null)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('#DEFAULT_ROUTES', () => {
|
|
140
|
+
it('should return an array of route objects', async () => {
|
|
141
|
+
const instance = createInstance()
|
|
142
|
+
const routes = await instance.DEFAULT_ROUTES
|
|
143
|
+
assert.ok(Array.isArray(routes))
|
|
144
|
+
assert.ok(routes.length > 0)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('should include routes for /, /schema, /:_id, and /query', async () => {
|
|
148
|
+
const instance = createInstance()
|
|
149
|
+
const routes = await instance.DEFAULT_ROUTES
|
|
150
|
+
const routePaths = routes.map(r => r.route)
|
|
151
|
+
assert.ok(routePaths.includes('/'))
|
|
152
|
+
assert.ok(routePaths.includes('/schema'))
|
|
153
|
+
assert.ok(routePaths.includes('/:_id'))
|
|
154
|
+
assert.ok(routePaths.includes('/query'))
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('should use permissionsScope when set', async () => {
|
|
158
|
+
const instance = createInstance({ root: 'content', permissionsScope: 'custom' })
|
|
159
|
+
const routes = await instance.DEFAULT_ROUTES
|
|
160
|
+
const rootRoute = routes.find(r => r.route === '/')
|
|
161
|
+
assert.ok(rootRoute.permissions.post.includes('write:custom'))
|
|
162
|
+
assert.ok(rootRoute.permissions.get.includes('read:custom'))
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('should fall back to root for permissions when permissionsScope is not set', async () => {
|
|
166
|
+
const instance = createInstance({ root: 'content', permissionsScope: undefined })
|
|
167
|
+
const routes = await instance.DEFAULT_ROUTES
|
|
168
|
+
const rootRoute = routes.find(r => r.route === '/')
|
|
169
|
+
assert.ok(rootRoute.permissions.post.includes('write:content'))
|
|
170
|
+
assert.ok(rootRoute.permissions.get.includes('read:content'))
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('should set validate: false and modifying: false on /query route', async () => {
|
|
174
|
+
const instance = createInstance()
|
|
175
|
+
const routes = await instance.DEFAULT_ROUTES
|
|
176
|
+
const queryRoute = routes.find(r => r.route === '/query')
|
|
177
|
+
assert.equal(queryRoute.validate, false)
|
|
178
|
+
assert.equal(queryRoute.modifying, false)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
59
181
|
})
|