adapt-authoring-api 2.2.0 → 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.
@@ -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
@@ -12,11 +12,11 @@ class AbstractApiModule extends AbstractModule {
12
12
  get DEFAULT_ROUTES () {
13
13
  const readPerms = [`read:${this.permissionsScope || this.root}`]
14
14
  const writePerms = [`write:${this.permissionsScope || this.root}`]
15
- const handler = this.requestHandler()
15
+ const handler = this.requestHandler.bind(this)
16
16
  return [
17
17
  {
18
18
  route: '/',
19
- handlers: { post: handler, get: this.queryHandler() },
19
+ handlers: { post: handler, get: this.queryHandler.bind(this) },
20
20
  permissions: { post: writePerms, get: readPerms }
21
21
  },
22
22
  {
@@ -33,7 +33,7 @@ class AbstractApiModule extends AbstractModule {
33
33
  route: '/query',
34
34
  validate: false,
35
35
  modifying: false,
36
- handlers: { post: this.queryHandler() },
36
+ handlers: { post: this.queryHandler.bind(this) },
37
37
  permissions: { post: readPerms }
38
38
  }
39
39
  ]
@@ -153,11 +153,6 @@ class AbstractApiModule extends AbstractModule {
153
153
 
154
154
  const config = await loadRouteConfig(this.rootDir, this, {
155
155
  schema: 'apiroutes',
156
- handlerAliases: {
157
- default: this.requestHandler(),
158
- query: this.queryHandler(),
159
- serveSchema: this.serveSchema.bind(this)
160
- },
161
156
  defaults: new URL('./default-routes.json', import.meta.url).pathname
162
157
  })
163
158
  if (config) this.applyRouteConfig(config)
@@ -241,26 +236,42 @@ class AbstractApiModule extends AbstractModule {
241
236
 
242
237
  /**
243
238
  * Applies route configuration loaded from routes.json.
244
- * Expands `${scope}` permission placeholders with `this.permissionsScope || this.root`.
239
+ * Resolves `${scope}`, `${schemaName}`, and `${collectionName}` placeholders
240
+ * throughout the route config (permissions, meta, etc.).
245
241
  * @param {Object} config The route config object returned by loadRouteConfig
246
242
  */
247
243
  applyRouteConfig (config) {
248
244
  /** @ignore */ this.root = config.root
249
245
  if (config.schemaName !== undefined) this.schemaName = config.schemaName
250
246
  if (config.collectionName !== undefined) this.collectionName = config.collectionName
251
- const scope = this.permissionsScope || this.root
252
- this.routes = config.routes.map(r => {
253
- if (!r.permissions) return r
254
- return {
255
- ...r,
256
- permissions: Object.fromEntries(
257
- Object.entries(r.permissions).map(([method, perms]) => [
258
- method,
259
- Array.isArray(perms) ? perms.map(p => p.replace('${scope}', scope)) : perms // eslint-disable-line no-template-curly-in-string
260
- ])
261
- )
262
- }
263
- })
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
264
275
  }
265
276
 
266
277
  /**
@@ -390,135 +401,129 @@ class AbstractApiModule extends AbstractModule {
390
401
  * Middleware to handle a generic API request. Supports POST, GET, PUT and DELETE of items in the database.
391
402
  * @return {Function} Express middleware function
392
403
  */
393
- requestHandler () {
394
- const requestHandler = async (req, res, next) => {
395
- const method = req.method.toLowerCase()
396
- const func = this[httpMethodToDBFunction(method)]
397
- if (!func) {
398
- return next(this.app.errors.HTTP_METHOD_NOT_SUPPORTED.setData({ method }))
399
- }
400
- let data
401
- try {
402
- await this.requestHook.invoke(req)
403
- const preCheck = method !== 'get' && method !== 'post'
404
- const postCheck = method === 'get'
405
- if (preCheck) {
406
- await this.checkAccess(req, req.apiData.query)
407
- }
408
- data = await func.apply(this, argsFromReq(req))
409
- if (postCheck) {
410
- data = await this.checkAccess(req, data)
411
- }
412
- data = await this.sanitise(req.apiData.schemaName, data, { isInternal: true, strict: false })
413
- } catch (e) {
414
- 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)
415
417
  }
416
- if (Array.isArray(data) && req.params._id) { // special case for when _id param is present
417
- if (!data.length) {
418
- return next(this.app.errors.NOT_FOUND.setData({ id: req.params.id, type: req.apiData.schemaName }))
419
- }
420
- data = data[0]
418
+ data = await func.apply(this, argsFromReq(req))
419
+ if (postCheck) {
420
+ data = await this.checkAccess(req, data)
421
421
  }
422
- if (method !== 'get') {
423
- const resource = Array.isArray(data) ? req.apiData.query : data._id.toString()
424
- this.log('debug', `API_${func.name.toUpperCase()}`, resource, 'by', req.auth.user._id.toString())
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 }))
425
429
  }
426
- res.status(this.mapStatusCode(method)).json(data)
430
+ data = data[0]
427
431
  }
428
- return requestHandler
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())
435
+ }
436
+ res.status(this.mapStatusCode(method)).json(data)
429
437
  }
430
438
 
431
439
  /**
432
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.
433
441
  * @return {function}
434
442
  */
435
- queryHandler () {
436
- const queryHandler = async (req, res, next) => {
437
- try {
438
- const opts = {
439
- schemaName: req.apiData.schemaName,
440
- collectionName: req.apiData.collectionName
441
- }
442
- const mongoOpts = {}
443
- // find and remove mongo options from the query
444
- Object.entries(req.apiData.query).forEach(([key, val]) => {
445
- if (['collation', 'limit', 'page', 'skip', 'sort'].includes(key)) {
446
- try {
447
- mongoOpts[key] = JSON.parse(req.apiData.query[key])
448
- } catch (e) {
449
- this.log('warn', `failed to parse query ${key} param '${mongoOpts[key]}', ${e}`)
450
- }
451
- delete req.apiData.query[key]
452
- } else {
453
- // otherwise assume we have a query field or option and store for later processing
454
- opts[key] = val
455
- }
456
- })
457
- // handle search parameter
458
- const search = req.apiData.query.search
459
- if (search) {
460
- 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)) {
461
453
  try {
462
- const schema = await this.getSchema(req.apiData.schemaName)
463
- if (schema && schema.built && schema.built.properties) {
464
- const searchableFields = Object.keys(schema.built.properties).filter(
465
- field => schema.built.properties[field].isSearchable === true
466
- )
467
- if (searchableFields.length) {
468
- // escape special regex characters to prevent ReDoS attacks
469
- const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
470
- const regex = { $regex: escapedSearch, $options: 'i' }
471
- const searchConditions = searchableFields.map(f => ({ [f]: regex }))
472
- // merge with existing $or if present
473
- if (req.apiData.query.$or) {
474
- req.apiData.query.$and = [
475
- { $or: req.apiData.query.$or },
476
- { $or: searchConditions }
477
- ]
478
- delete req.apiData.query.$or
479
- } else {
480
- req.apiData.query.$or = searchConditions
481
- }
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
482
488
  }
483
489
  }
484
- } catch (e) {
485
- this.log('warn', `failed to process search parameter, ${e.message}`)
486
490
  }
491
+ } catch (e) {
492
+ this.log('warn', `failed to process search parameter, ${e.message}`)
487
493
  }
488
- req.apiData.query = await this.parseQuery(req.apiData.schemaName, req.body, mongoOpts)
489
- // remove any valid query keys from the options
490
- Object.keys(req.apiData.query).forEach(key => delete opts[key])
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])
491
498
 
492
- await this.requestHook.invoke(req)
499
+ await this.requestHook.invoke(req)
493
500
 
494
- await this.setUpPagination(req, res, mongoOpts)
501
+ await this.setUpPagination(req, res, mongoOpts)
495
502
 
496
- let results = await this.find(req.apiData.query, opts, mongoOpts)
503
+ let results = await this.find(req.apiData.query, opts, mongoOpts)
497
504
 
498
- results = await this.checkAccess(req, results)
505
+ results = await this.checkAccess(req, results)
499
506
 
500
- // If checkAccess filtered some results, fetch more to fill the page
501
- const pageSize = mongoOpts.limit
502
- if (pageSize && results.length < pageSize) {
503
- let fetchSkip = mongoOpts.skip + pageSize
504
- while (results.length < pageSize) {
505
- const extra = await this.find(req.apiData.query, opts, { ...mongoOpts, skip: fetchSkip })
506
- if (!extra.length) break
507
- const filtered = await this.checkAccess(req, extra)
508
- results = results.concat(filtered)
509
- fetchSkip += extra.length
510
- }
511
- 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
512
517
  }
518
+ if (results.length > pageSize) results = results.slice(0, pageSize)
519
+ }
513
520
 
514
- results = await this.sanitise(req.apiData.schemaName, results, { isInternal: true, strict: false })
521
+ results = await this.sanitise(req.apiData.schemaName, results, { isInternal: true, strict: false })
515
522
 
516
- res.status(this.mapStatusCode('get')).json(results)
517
- } catch (e) {
518
- return next(e)
519
- }
523
+ res.status(this.mapStatusCode('get')).json(results)
524
+ } catch (e) {
525
+ return next(e)
520
526
  }
521
- return queryHandler
522
527
  }
523
528
 
524
529
  /**
@@ -2,25 +2,147 @@
2
2
  "routes": [
3
3
  {
4
4
  "route": "/",
5
- "handlers": { "post": "default", "get": "default" },
6
- "permissions": { "post": ["write:${scope}"], "get": ["read:${scope}"] }
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
+ }
7
46
  },
8
47
  {
9
48
  "route": "/schema",
10
49
  "handlers": { "get": "serveSchema" },
11
- "permissions": { "get": ["read:schema"] }
50
+ "permissions": { "get": ["read:schema"] },
51
+ "meta": {
52
+ "get": { "summary": "Retrieve ${schemaName} schema" }
53
+ }
12
54
  },
13
55
  {
14
56
  "route": "/:_id",
15
- "handlers": { "put": "default", "get": "default", "patch": "default", "delete": "default" },
16
- "permissions": { "put": ["write:${scope}"], "get": ["read:${scope}"], "patch": ["write:${scope}"], "delete": ["write:${scope}"] }
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
+ }
17
120
  },
18
121
  {
19
122
  "route": "/query",
20
123
  "validate": false,
21
124
  "modifying": false,
22
- "handlers": { "post": "query" },
23
- "permissions": { "post": ["read:${scope}"] }
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
+ }
24
146
  }
25
147
  ]
26
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-api",
3
- "version": "2.2.0",
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",
@@ -9,8 +9,8 @@ function createInstance (overrides = {}) {
9
9
  instance.schemaName = undefined
10
10
  instance.collectionName = undefined
11
11
  instance.routes = []
12
- instance.requestHandler = () => function defaultRequestHandler () {}
13
- instance.queryHandler = () => function queryHandler () {}
12
+ instance.requestHandler = function defaultRequestHandler () {}
13
+ instance.queryHandler = function queryHandler () {}
14
14
  instance.serveSchema = function serveSchema () {}
15
15
  Object.assign(instance, overrides)
16
16
  return instance